这次讲一个可以说过时,但是也可以说永不过时的内容:C/C++的编译与链接。说它过时,因为我们学习C语言的时候,第一个讲的就是这个知识点;而它不过时,是因为它是程序运行的基础。比如在一个C/C++工程中,可能会有很多的 .c/.cpp.h/.hpp文件,这时候我们要运行这个工程,就离不开编译与链接。

接下来将在命令行一步步地进行这个过程。

编译与链接

C/C++的执行过程包括预处理(Prepocessing)、编译(Compilation)、汇编(Assembly)、链接(Link)四步。我们常常忽略预处理与汇编,直接把这个过程称为编译与链接;有时候甚至直接用编译来代指这整个过程(虽然可能是不标准的说法)。

下面两张图总结C/C++的编译与链接:

预处理(Preprocessing)

以 test.c 为例,对它进行预处理的命令是:cpp test.c > test.i。它的作用是将 test.c 中包含的头文件和定义的宏 (Macro) 展开,得到 test.i 文件。

.i 文件,它也是 C 文件的一种,即里面的代码也是C语言。

程序中进行预处理的标志是 #,常见的预处理指令有 includedefineifdefifndefendif等等。预处理不是 C/C++ 等程序设计语言自带的功能。完成预处理的是编译器中的预处理程序。但我们又通常把预处理和编译这两个过程分开,把预处理视作编译的前置步骤。

头文件的预处理

#include <stdio.h>可能是我们平时用到最多的形式了。

include告诉要引用后面的文件。<>告诉预处理程序在系统自带的目录下面去寻找头文件。如果引用自己写的头文件,则应该使用 "",它告诉预处理程序在当前文件的目录下面去寻找头文件。如果头文件在当前目录的子目录include下,则使用 "include/MyHead.h"的方式。(当然也可以在运行的时候使用 -I指令指定头文件的位置,后面讲)。

宏的预处理

我们经常使用的宏的形式为:#define PI 3.14。用这种方法定义一个符号常量,便于代码的维护。

我们也可以使用宏来实现求两个数的最大值:#define MAX(x,y) ((x)>(y)?(x):(y))。这样我们在代码中使用 MAX(1,2)就会在预处理是被自动替换成 ((1)>(2)?(1):(2)),最终的结果为2,没有问题。

宏是一种高效的用法,它可以避免调用函数调用时的额外开销。但是,它的缺点就是太傻了——它直接替换使用宏的部分,可能导致替换后的运算顺序和预期不同。

与宏有关指令的除了 #define以外,还有 #ifdef#ifndef#endif等等。它们在工程中用得很多,下面进行简单的介绍。

#ifdef……#endif告诉预处理程序如果定义了某个宏,则执行之间的代码块,否则就忽略;#ifndef……#endif反之。

#ifdef#ifndef在工程中经常用到。此外,如果我们去看一些头文件,就会发现很多 #ifdef……#endif#ifndef……#endif这样的代码。它的作用是防止头文件的重复包含以及函数的反复声明。


在命令行输入 cpp test.c > test.i,得到 .i文件。

我们来对比一下 test.c 和 test.i。

  • test.i 的头文件都展开了,所以main函数在468行,前面都是 stdio.h 的内容。
  • 宏都进行了替换。如 MAX(2.4)被替换为了 ((2)>(4)?(2):(4))
  • 由于 MIN(x,y)的宏定义被注释掉了,所以test.i中 #ifdef MIN(x,y)#endif之间的代码消失了。
  • 预处理不会检查语法正确与否。生成的test.i 也是可以直接运行的,前提是你的语法得正确。

编译(Compilation)

编译就是编译器将源代码转化为汇编代码的过程。

除了将源代码转化为汇编代码,编译器还要做很多工作,如代码的优化等。编译相关的内容可能会在计科的后续课程《编译原理》中学到。

现在广泛使用的编译器主要有gcc/g++、clang、MSVC等。不同的编译器对代码的处理不同。好像有的牛逼些的公司也开发了自己的编译器,据说之前校招的时候就在咱学校招了几个做编译器的,属于是稀缺人才了。


命令行中对刚才的 test.i 进行编译:输入 gcc -S test.i,得到 test.s。

我们来看看生成的 test.s,这是一个汇编代码。

.asm 也挺有用的。比如我们可以通过数汇编代码的行数来比较一些操作或算法的优劣。比如,我这里可以通过汇编代码比较直接寻址和间接寻址的效率的高低。

汇编(Assembly)

汇编即将汇编代码生成二进制文件的过程。

在命令行输入 as -o test.o test.s,得到 test.o(bj)。.o文件算是中间文件,一般我们的目标文件是 .exe(可执行文件)或 .lib(静态链接库)或 .dll(动态链接库)。所以最后的 .o文件都要clean,如果没有clean的话就会生成很多的 .o垃圾。

对于多文件编程,每个源文件都要生成一个.o文件,这样的.o文件很多,我们下一步去链接的时候就很麻烦。所以,我们将 .o 文件打个包,生成一个 .lib 文件,这样链接的时候就只需要链接这个文件就行了。

值得一提的是,gcc的库文件都是 lib**.a 的形式——这是库文件的标准形式。

为了体现各文件的依赖关系,下面换一个例子。

可以看到,hellomake.c 包含了 hellofunc.h 这个头文件,并调用了外部的一个函数;hellofunc.h 中声明了函数原型;hellofunc .c 则对 hellofunc.h 中声明的函数进行了实现。

先将hellomake.c和hellofunc.c分别生成 .o文件,然后再将这两个.o文件链接起来,生成可执行文件。下图中一二行可以用第三行代替。

在我看来,链接算是很麻烦的一步了。我们平时常用的单个文件链接标准库固然简单,但是,当一个项目大起来的时候,不仅要链接系统的标准库,还要链接系统的非标准库,以及用户自己写的库。这时候要链接这一大堆文件就无比麻烦。

链接系统的标准库

这个很简单,你根本不需要指定路径。编译器会在系统路径(path)下面去搜索。但是,为了搜索的文件不是太多,编译器只会搜索标准库std。

链接系统的非标准库

系统的非标准库,如数学函数库(libm.a),则需要我们手动去链接(有的时候?这可能要看脸?)。

这是我们通过 -lm指令,告诉编译器去链接libm.a这个库。m为库名,是库文件名减去前缀lib和后缀.a的剩余部分。

链接用户自定义的库

告诉编译器链接用户自定义的库,这大概就是所谓的令人头疼的配置环境了吧?

如果这些库都在该工程文件的子文件夹下,则直接使用 -l命令即可,和链接系统的非标准库的做法一样。不过还得注意头文件的路径,要么在 #include "include/.h"时说明搜索路径,要么通过 -I命令添加头文件的绝对或相对路径。


如果我们每次运行时都在命令行输入这样复杂的命令,肯定很麻烦,也不便于查看和修改之前的命令。于是,就有了make这种脚本工具来帮助我们来管理命令。

make初步

这里仅对make进行一个感性的认识。

如图,我们先在makefile中写好命令。然后在命令行中输入make,则会自动执行我们在make中写好的命令。这就是最简单的makefile,它的作用好比一个脚本。

当文件多起来的时候,这样直接生成一个可执行文件肯定时不适用的。一是命令太长,二是我们有时候希望使用动态链接库。所以我们把指令分开来写,先生成 .o 文件,再生成可执行文件,如下图。

这样的好处是得到中间文件 .o ,可以生成库,供自己和别人使用。但是坏处就是会生成大量的 .o 文件,当文件多起来的时候,就会显得很乱。

解决方案一是在makefile中添加删除 .o 文件的命令。make有一个clean的指令,但我还不太会,所以直接用windows的 del 指令。

但是,当我们需要使用中间文件 .o 的时候,这种方法就不适用了。

所以解决方法二是将生成的文件输出到 build 目录下。使用 -o 指令指定输出文件的位置即可。(同时把可执行文件输出到 bin 目录下,头文件放到 include 目录下,这样就有点工程的感觉了。)