Preface

本文为The Cherno的C系列教程中关于C编译链接机制的几节内容的总结。

How C++ Works

简而言之,源文件(文本)通过编译器转化为二进制文件,这个二进制文件可以是可执行文件,也可以是某种库(Library)。

任何#开头的语句都是预处理语句(Preprocessor Statement),顾名思义,它们在编译之前进行。
#include<iostream>为例,它做的就是找到叫"iostream"的文件,将其中所有内容复制粘贴到目前这个文件中。(还真是复制粘贴,具体看后面)

编译阶段

由于函数定义和Main分成不同cpp文件,在编译Main的时候会因为找不到函数而出错。此时我们在Main.cpp中提供一个函数声明,“我们向编译器声明有这么一个函数,并且编译器真的会相信我们,因为编译器不关心这个函数到底在何处定义、被如何定义”。

tips

  1. 声明(declaration)和定义(definition)的区别
  2. 提供声明后,即便我们不提供具体定义,对Main的编译也不会产生如何问题

链接阶段

编译器是对每个文件单独进行编译的,那么它下一步又是怎么知道我们声明的这个函数在哪里且被定义成什么样子呢。很不幸,答案是它并不知道,就像前面说的它不关心函数在何处被如何定义,它只是相信我们的声明。

那么最终程序的正确运行,就必须依赖链接器(Linker)。
当我们的文件被编译之后,链接器会去找我们声明的函数的定义,然后和我们Main里面的调用联系起来。如果在这一步中,链接器找不到函数定义,我们就会收到一个错误,也就是所谓的LinkingError。

总结

主要经历几个阶段:

  1. 预处理语句在编译之前被评估(evaluate),如上所述,就是将文件拷贝至目前文件中。
  2. 然后文件会被编译,在编译阶段,编译器把C++代码转化成实际的机器码。每个.cpp文件都会被编译成一个object文件,windows下其扩展名为.obj。
  3. 链接器(Linker)将所有.obj文件链接组成一个exe文件。

tips

  1. error list窗口其实是解析output窗口,找到其中的error关键字,然后从中截取信息放入到list。因此,error list中的报错只能当作一种概述用于参考,想找到更具体的错误信息必须从output窗口中。
  2. Visual Studio中编译单个文件不关linker的事,它不会进行link,编译后会生成obj文件。而构建(build)整个Solution会直接对下属所有文件进行编译链接,而且会生成exe文件。

How C++ the Compiler Works

编译器在进行编译时,实际上做了好几件事情。
首先它预处理我们的代码,所有预处理语句都会在此时被评估,之后又会进行Tokenizing(标记解释), Parsing(解析)…

总之就是把C++文本转化成编译器能懂能处理的语言,基本上就是以抽象语法树(Abstract Syntax Tree)的形式表达我们的代码。说到底,编译器的工作就是把代码转化成数据(Constant Data)或者指令(Instruction)。创建完抽象语法树后,就可以开始产生真正在cpu执行的机器码。

注意
C++中,文件的概念其实并不存在,比如所谓的.cpp文件其实只是我们用来装代码的东西,我们只是用它给编译器提供源代码而已。也就是说,我们完全可以建一个其他并不存在的后缀的文件让编译器编译,只要能让编译器把它也当成.cpp文件来编译就行,我们依然会得到一个正确的obj。In a word, in C++, Files Have No Meaning.

编译器把文件当成一个个翻译单元(Translation Unit)单独进行编译,然后生成一个个对应的obj。
一个翻译单元由其实现文件(.cpp)和其直接或间接包含的所有标头(.h, .hpp etc.)组成。如果在cpp文件中include其他cpp,则在编译时候它们也会被当成一个Translation Unit。
简而言之,如果所有cpp都不互相include,每个cpp文件都是一个Translation Unit。

一些演示

// in Main.cpp
#include<iostream>

void Log(const char* message);

int main() {
	Log("Hello World!");
	std::cin.get();
}

// in Log.cpp
#include<iostream>

void Log(const char* message) {
	std::cout << message << std::endl;
}

// in Math.cpp
int Multiply(int a, int b) {
	int result = a * b;
	return result;
#include"EndBrace.h"

// in EndBrace.h
} //就是一个"}",你没看错

可以看到Math.cpp,EndBrace.h中的代码很怪,但是Math.cpp能编译成功。这也解释了为何前文说#include其实就是找到对应文件将其中内容拷贝过来,同时这也可以解释为什么有时候我们明明没写几行代码,但是生成的obj却有几十kb,因为有其他文件中成千上万行的代码被拷贝过来了。

如果要进一步验证,可以在VS中修改ProjectProperties,其中Configuration Properties->C/C+±>Preprocessor中的Preprocess to a File属性改为Yes。此时编译就会产生.i文件,这就是预处理后的代码文件,对比可以看到它和正常写法生成的.i文件产生一样的效果。这里就不演示了。
测试完记得把设置改回来,不然不会生成obj文件

关于obj文件,我们也可以生成一个稍微可读一点的代码,在VS中修改ProjectProperties,其中Configuration Properties->C/C+±>OutPut Files中的Assembler Output的属性改为Assembly-Only Listing(/FA)。此时编译会生成.asm文件,这里面就是汇编指令。

How C++ the Linker Works

链接器的工作就是找到每个符号和函数的位置并将它们链接到一起。编译时的每个Translation Unit都不互相关联。即便没有其他外部文件中的函数,程序依然需要借助链接器找到程序的入口点(main函数)。每个.exe文件都必须有一个入口点,这个入口点可以自定义,并不非得是main函数,但是一般都是。

链接器链对比的其实是函数名和函数签名(Signature)。因此,如果两个函数有相同的函数名和函数签名,那么链接器就不知道该怎么链接了(重定义问题)。

思考
结合链接器工作的原理和上述#include语句的实质进行思考:

  1. 为什么我们不该在.h头文件中定义函数而只是进行声明?
    因为当我们在其他文件中include这个头文件时候就是将代码拷贝过来,我们因此有了一段函数定义,并且它的函数名和函数签名与头文件对应的源文件中定义的函数一模一样,此时链接器就不知道需要链接的到底是我们拷贝到Main的这个函数还是另一个文件中定义的函数。当然如果非要在.h文件中定义函数,可以将其定义为static或者inline。
  2. 为什么头文件要加#Pragma once
    和上面类似的道理。

以我个人的理解一句话总结就是,Linking要不重不漏。

tips
关于报错。编译阶段的错误,如语法错误,名称会以C开头,比如error C2143…而链接阶段的错误会以LNK开头,如LNK1561。

一些演示

// Math.cpp
#include<iostream>

void Log(const char* message);

int Multiply(int a, int b) {
	Log("Multiply");
	return a * b;
}

int main() {
	std::cout << Multiply(5, 8) << std::endl;
	std::cin.get();
}

// Log.cpp
#include<iostream>

void Log(const char* message) {
	std::cout << message << std::endl;
}

以上述代码为例。
如果不写Math.cpp中的main函数,会报错LNK1561。必须定义一个入口点。
如果不写Math.cpp中Log函数的声明,会报错C3861。函数Log未找到。

如果将Log.cpp中的Log函数改成其他名称而Math.cpp中不修改,则会报错LNK1120。但是如果将Math.cpp中对Log函数的使用注释掉就不会报错,因为我们没有使用该函数,所以链接器不需要通过链接来调用它。

但是,十分有意思的是,如果我们将Math.cpp中主函数对Multiply的调用注释掉,就又会报链接错误。
我们没有调用Multiply函数,更不会调用在其中调用的Log函数,那又为什么会产生这个问题呢?

原因其实很简单,从技术上说,我们在Math.cpp中定义了Multiply函数,它有可能在其他文件中被调用,因此链接器必须链接它,因此就会链接其中的Log函数,从而产生错误。

那么,顺着这个思路想,如果我们能告诉编译器这个Multiply函数我们将只会在这个文件中调用,鉴于它在该文件中未被调用,那么其中的Log也不会被调用,因此没有链接它的必要。这样的话,我们是否可以解决这个报错呢?
Of course we can! 我们在Multiply函数前加上static,这意味着这个函数只为了当前这个Translation Unit定义,因为当前的Translation Unit中没有调用,所以不会有链接错误。

改后的Math.cpp是这样的

Math.cpp
#include<iostream>

void Log(const char* message);

static int Multiply(int a, int b) {
	Log("Multiply");
	return a * b;
}

int main() {
	// std::cout << Multiply(5, 8) << std::endl;
	std::cin.get();
}