链接器是如何工作的

1. 概述

什么是链接器,C++链接实际是做什么。

链接是一个过程,当我们从源C++文件转到实际的可执行文件二进制文件,第一阶段便是编译源文件(编译器是如何工作),一旦我们把文件编译好,便进入第二阶段链接目标文件,也就是我们需要通过一个叫做链接的过程。现在链接的主要焦点是找到每个符号和函数在哪里,并把他们连接起来。

记住,每个文件被编译成一个单独的目标文件,一个翻译单元,他们彼此之间没有关系,这些文件不能交互。所以如果我们决定把我们的程序分割成多个C++文件,这当然也很常见,那么我们就需要一种方法把这些文件连接在一起,使其成为一个项目,而这就是链接器的主要目的和要做的事情。

即使你不打算将我们的程序分割成多个C++文件,在外部文件中没有函数,例如,你就在一个文件中写出完整的整个程序,应用程序仍然需要知道入口点在哪里,也就是说,main函数在哪里,当你实际运行你的应用程序时,C++运行库会说,这是main函数,我要跳到这里,然后开始执行代码。这实际上是你应用的起始位置。所以它仍然需要把main函数链接起来,即使你没有其他所有的文件,最好的解释方法就是展示一些例子。

2.案例

我们现在有一个简单的项目,里面只有一个Math.cpp源文件,内容如下,创建项目过程省略,可参考C++是如何工作的

里面有两个函数,LogMultiplyMultiply函数实际上调用了Log函数,打印出"Multiply"单词到控制台,然后返回a * b,非常简单的东西。

然而,这并不是一个实际的应用程序,因为它没有包含主函数main。首先你要意识到是,编译有两个阶段,有编译,有链接。 实际上有一种方法可以区分VS中的两个阶段。如果是使用的单文件编译,那么只有编译才会发生,链接将永远不会发生。但是,如果你编译的是你的项目,或者如果你按下F5运行你的项目,那么,它就会先走编译,然后在走链接。

这里,我们先单文件编译math.cpp文件

可以看到,编译通过了,没有报错。一切正常,编译生成了obj目标文件。

1. 链接错误-错误代码'LNK'开头

现在,我编译整个项目,右键单击我的项目并点击生成。

可以看到,编译失败了,我们得到一个链接错误fatal error LNK1561: 必须定义入口点。那是因为我错过了入口点,我的main函数。因为我们的编译被分成这两个阶段,通过编译和链接,我们实际上得到了不同类型的错误,与每个阶段相关联的消息

2. 编译错误-错误代码'C'开头

如果我犯了语法错误,这当然是一些编译器要处理,如我们去掉multiply返回语句的结束符号;,如下

单文件编译代码,

亦或是编译整个项目

可以看到,错误信息error C2143: 语法错误: 缺少";"(在"}"的前面)会告诉我们,实际上得到一个叫做C2143的错误,当然,这是语法错误C2143是这种类型的错误代码,你会注意到它实际上以字母C开头,这告诉我们这是发生在编译阶段

如果我们修复了语法错误问题,然后构建整个项目。

可以看到错误信息LINK : fatal error LNK1561: 必须定义入口点中列出的错误代码是以字母LNK开头,这个代表链接,它告诉我们,这个错误发生在链接阶段。知道这点很重要,你会得到什么样的错误信息,是编译错误还是链接错误。当然你需要知道这些错误,然后才能修复这些错误。在这种情况下,我们得到一个错误,告诉我们,必须定义入口点,这是因为项目属性配置中我们默认的将其编译为了一个应用程序。

3. 程序入口点配置

打开项目属性配置

常规->项目默认值->配置类型中,默认值为应用程序(.exe)。每个exe文件,必须有某种入口点。

如果我们到链接器->高级配置中。

可以看到,我们可以指定一个自定义的入口点。入口点不一定是main函数,只要有一个入口点就行了。不过,通常它是main函数,你一般也是用main函数作为入口。但是,你要知道,入口点不一定必须是一个叫main的函数,它可以是任何函数。

4. 添加main函数

回到代码,我们将main函数写出来。

然后,再次构建我们的项目

可以看到,我们不再得到那个链接错误,我们成功的生成了exe文件。

好了,现在我们把multiply函数的值打印出来。所以我们应该看到这个message被log了下来,也就是记录打印了下来,然后函数的结果40也被打印了出来。让我们添加一个cin.get(),让控制台不会立即关闭。代码修改如下。

然后我们按下F5或点击工具栏的本地Windows调试器按钮,运行项目。

可以看到,控制台打印了Multiply40。因此,我们的应用程序似乎运行正常。

5. 函数按功能拆分到多个文件

好了,现在假设我们有多个文件。例如,这个Log函数实际上并不需要在这个math.cpp文件内定义,因为这只是记录消息。那为什么我不能有一个单独的文件来包含我所有的日志相关函数?

说干就干,我们右键src,然后添加新文件,创建一个Log.cpp文件。 将Math.cpp文件中的Log函数剪贴到Log.cpp文件中

6. 错误信息分析

单文件编译Math.cpp文件

我们会得到一个错误,从错误信息,你可以知道这是一个编译错误,因为错误代码以字母C开头。错误信息告诉我们没有找到Log,因为这个文件不知道存在一个名为Log的函数。

我们复制Log.cpp文件中Log函数的函数签名,放入到Math.cpp文件Multiply函数的上方,如下

好了,math.cpp文件这里就有了一个log函数的声明

单文件编译math.cpp文件

可以看到编译通过了。

现在,我们继续编译整个项目

可以看到这里有几个错误,错误信息告诉我们,在Log.cpp文件中cout没找到,因为我们没有包含iostream。现在我们在log.cpp文件上方加上iostream,如下

然后,我们再次来编译整个项目

可以看到,很好,现在编译通过了。

1. error LNK2019:无法解决的外部符号

下面,让我们看看一种类型的链接错误,我们可能会遇到,这叫做无法解决的外部符号 unresolved external symbol,这就是当链接器找不到他需要的东西时发生的错误

回到代码,我们修改下Log.cpp文件的Log函数,将函数名改为Logr

而math.cpp文件不动,仍然希望调用的是Log函数

单文件编译math.cpp文件

可以看到,编译可以通过。这是当然的,因为这个没有链接,所以我们所做的就是检查以确保一切顺利,这里编译正确。

void Log(const char* message);认为某个地方有一个log函数,单文件编译这里只需要保证有这么一个声明就行了。而真正去找到这个log函数是链接的工作,是找log函数的阶段,而不是编译阶段

如果我现在构建整个项目

可以看到,我们实际上得到了一个错误,这是一个链接错误,因为你可以看到错误代码是以LNK字母开始,表示未解决的外部符号。现在错误信息告诉我们丢失了什么符号,void __cdecl Log(char const *),它是log函数。错误信息甚至告诉我们,我们在哪里引用了log函数,是在一个叫做multiply(int __cdecl Multiplay(int,int))的函数中引用了这个log函数。

在multiply函数中,我们调用了log函数。它实际上找不到哪里可以链接到这个log函数的定义。所以,它必然会给出一个错误,这是因为当我们项目在编译构建处理代码时,当它试图去找log函数时,它并不知道log函数在哪里。现在如果我到Multiply函数中,注释掉这个log函数的调用,我们不去调用它。

然后我们再次构建整个项目

可以看到,没有错误。发生这种情况的原因是,我从来没有调用过log函数,所以链接器不需要去链接这个log函数

2. 什么时候出现链接

另一个有趣的是,如果我确实调用了log函数,而是在main函数中注释掉调用Multiply函数,没有调用Multiply函数,就不会调用log函数。如下。

构建我们的项目

然而,我们仍然得到了一个链接错误。你可能会说,为什么会这样,我没有在任何地方调用multiply函数,为什么它还是会报链接错误?我们可不可以说这些没用到的死代码一点意义都没有呢?大错特错,因为在这个文件这里,虽然我们不用multiply函数,但技术上讲,我们在另一个文件中可能会使用它,所以链接器确实需要链接它。如果我们能告诉编译器这个multiply函数,我只会在这个文件中使用它,我们当然可以去掉这种链接的必要性,因为multiply从来不会被本文件或外部文件调用,也就是真正的从不需要调用log函数

如果我们在multiply函数之前加上静态 static这个修饰词,这基本上意味着这个multiply函数只被声明在当前这个翻译单元中,也就是math.cpp文件。更多详细的static关键字信息

如果我们编译整个项目

可以看到编译通过了,我们不会得到任何链接错误,因为multiply函数在这个math.cpp文件中从不被调用。如下。

如果我们取消掉multiply函数调用的注释,如下

编译整个项目

可以看到,我们会得到链接错误

在以上的例子中,我们实际上修改了函数的名字,然而,这不仅仅是函数的名称很重要。

如果我们将这个函数名字改回来。

然后编译项目

可以看到不会出现任何错误。

如果我们把log函数的返回类型改为int,返回结果为0或类似的值,如下。

然后再次编译项目

可以看到,我们得到了一个错误。因为在Math.cpp中,我们指定这个log函数是一个返回值是void的函数,所以正因如此,我们要寻找返回值为void,有相同参数且名为log的函数

如果回到log.cpp,修改log函数的返回为void,去掉返回值。如下

然后编译项目

一切正常。

但如果我给log函数的定义添加另一个参数,如下保存

编译项目

这将再次得到一个链接错误,因为math.cpp期望的log函数没有另一个参数

你会看到我们到这里的链接错误消息void __cdecl Log(char const *),它实际上需要一个返回值为void的,函数名为Log且必须只有一个参数的函数char const *是一个常量char指针,就是这样。如果它找不到确切的函数定义,然后你会得到一个链接错误

让我们回到log.cpp文件,只要删除这个level参数,这样我们的程序就可以再次工作。

然后编译项目

编译通过,我们不应该得到任何链接错误。

3. error LNK1169:多重符号定义

另一种链接错误很常见,是我们的有重复的符号。换句话说,我们有多个函数或变量具有相同的名字和签名多个名字相同的函数有相同的返回值,且有相同的参数,如果这种情况发生了,我们就有麻烦了。我们陷入困境的原因是因为链接器不知道该链接到哪一个

回到代码

在log.cpp文件中,添加一个函数,复制粘贴下Log函数,如下

然后编译我们的项目

可以看到,我们实际上得到了一个编译错误void Log(const char *)这个函数已经有一个函数体,编译器告诉我们,你犯了一个错误,下面那个Log函数是无效的,编译器可以直接帮助我们解决这些问题。因为这一切都发生在一个文件中,错误发生的时候,还没有开始链接。

但是,如果我把log.cpp文件中第二个log函数移到其他文件中,如移到math.cpp文件中,如下

且math.cpp文件中log声明仍然保留,log声明只是一个声明,在这个文件中我们只有一个log的定义,所以单文件编译math.cpp文件,它不会给我们一个编译错误

单文件编译math.cpp文件

可以看到编译通过了,没有问题。

但是,我如果编译整个项目

可以看到,错误信息中告诉我们log函数已经在 Log.obj 中定义,找到一个或多个多重定义的符号。在这种情况下,链接不知道,log函数链接到哪一个。是链接math.cpp里面的呢,还是链接log.cpp里面的。链接器它不知道,你可能认为这种类型的错误不是经常会发生的事,你会做的更好。然而,这可能会悄悄发生在你身上,我将向你们展示几种方法来表示,这可能发生。

5. 多重符号定义发生场景

首先,我们修改math.cpp文件,去掉这个额外的log函数定义。

然后编译项目

这样,我们的项目可以编译成功。

现在,我们创建一个头文件Log.h

现在将Log.cpp中的log函数剪切到Log.h文件中,确保我在头文件中声明且定义了log函数。如果我回到log.cpp文件中,我要写一些其他的函数,如,InitLog函数调用Log函数,然后说它已经初始化了,如下

当然,如果我们现在尝试编译

我们会得到一个错误,因为我们的Log.cpp文件需要log函数的声明

所以我们要在log.cpp文件中添加#include "log.h"头文件。include的使用见#include 预处理

回到math.cpp文件,去掉之前的那种log声明void Log(const char* message);,使用include头文件#include "Log.h"的方式,如下。

现在,我们调用log函数的地方有两处一处是math.cpp里面的Multiply函数中调用一处是log.cpp的InitLog函数中调用

我们没有调用InitLog函数,没关系,不用担心。

现在编译我们的项目

可以看到,我们得到了一个错误,错误信息告诉我们log函数已经在 Log.obj 中定义。我们找到一个或多个多重定义的符号。然而,你可以看到,我们实际上只有一个log的定义,它在log.h文件中,为什么它还会报多重符号错误?我们实际真的就只有一个log的定义吗?这个就回到了include预处理语句的工作原理#include 预处理。虽然我们在代码中写的只有在log.h文件定义了log函数,当我们使用了#include包含头文件时,我们会取头文件中所有的内容,并把它放在#include语句的出现的地方,这是实际上相当于我们调用了几次#include "Log.h",就定义了几次Log函数。所以实际发生的如下图。

这样,我们有了2个同样的函数定义,所以导致了多重符号错误

那么我们该如何解决这个问题呢?我们有几个选择,

如果我们撤销刚才的复制操作。log.cpp文件和math.cpp文件如下

1. static解决多重符号定义错误

我们可以在log.h文件定义log函数时,给log函数加上static修饰。

这意味着在链接log函数时,log函数只能是内部函数。也就是说在log.cpp和math.cpp文件中的log函数,只能是各自文件的内部函数,与外部文件互不干扰,就像math.cpp的multiply函数一样,所以log.cpp和math.cpp文件都会有自己版本的log函数,这个自己版本log函数对任何其他的obj文件都不可见

如果我们现在编译这个项目

可以看到,我们不会得到任何链接错误

2. inline解决多重符号定义错误

我们还有另外一个方法那就是inline,更多信息见inline

当然inline的意思是,获取我们inline修饰函数实际的函数体,并在该函数被调用的地方替换为函数体

如log.h文件这里inline修饰的log函数的函数体就是std::cout << message << std::endl;

调用的地方有

  • log.cpp文件中的InitLog函数

    Log("Initialized Log");函数调用替换为std::cout << message << std::endl;函数体。

  • math.cpp文件中的multiply函数

    Log("Multiply");函数调用替换为std::cout << message << std::endl;函数体。

如下图

所以,如果我们编译项目

可以看到,编译也是通过的。

3. 头文件声明解决多重符号定义错误

还有一种方式可以解决多重符号问题,在这种情况下,我可能会这么做,那就是把log函数的定义从log.h头文件移到一个翻译单元中。因为现在的情况是这个log函数的定义列入了两个翻译单元log.cpp和math.cpp,所以才导致的多重符号错误。我们可以把log函数的定义搬到第三个翻译单元或者我们可以把log函数的定义放进这些现有的翻译单元,当然log函数与logging的功能有关,所以我们把log函数的定义放入到log.cpp这个翻译单元中。因此将这个log函数的定义剪切放入到log.cpp中,去掉inline(可以不需要了),然后回到log.h头文件中声明log函数,现在这个log.h头文件只有log函数的声明。如下

log函数的定义被放在了log.cpp中,在我们项目的"一个翻译单元"中。然后在math.cpp的Multiplay会在main函数中被调用,而log函数会在math.cpp的Multiplay函数中被调用。

如果我们现在构建项目

可以看到编译没有链接错误。编译通过了。

这就是链接器以及关于链接和如何链接工作。记住,在工作结束的时候,链接器会带走我们所有的目标文件,并将他们链接在一起。它也将拉近我们可能用到的其他任何其他库,例如C运行时库。如果有必要,C++的标准库,平台的api,还有很多其他的东西。从许多不同的地方链接是很常见的。还有其他不同类型的链接,我们有静态链接和动态链接,后续的文章中会继续深入。

上一篇:编译器是如何工作的

相关推荐
六点半88827 分钟前
【C/C++】速通涉及string类的经典编程题
c语言·开发语言·c++·算法
汤姆和杰瑞在瑞士吃糯米粑粑29 分钟前
string类(C++)
开发语言·c++
学霸小羊1 小时前
C++游戏
c++·游戏
码农豆豆2 小时前
4.C++中程序中的命名空间
开发语言·c++
Joker100852 小时前
C++初阶学习——探索STL奥秘——标准库中的priority_queue与模拟实现
c++
怀九日2 小时前
C++(学习)2024.9.19
开发语言·c++·学习·重构·对象·
KookeeyLena82 小时前
如何限制任何爬虫爬取网站的图片
开发语言·c++·爬虫
m_Molly2 小时前
vs2022配置opencv==4.9.0(C++)
c++·opencv
charon87782 小时前
Unreal Engine 5 C++: 编辑器工具编写入门(中文解释)
c++·ue5·编辑器·游戏引擎·虚幻
Ddddddd_1582 小时前
C++ | Leetcode C++题解之第421题数组中两个数的最大异或值
c++·leetcode·题解