编译和链接以及makefile
问题引出,为什么我们会忽略编译和链接这个步骤
- 一定都会用到但却很少被重视的步骤——编译和链接,通常这两个步骤被我们的IDE封装的很完美,我们一般都是一件构建。
- 但是一旦遇到错误的时候,尤其是链接相关的错误,很多人就束手无策了,所以今天就跟大家一起深入探讨一下编译和链接的整个过程。
编译
- 首先,什么是编译呢,编译的过程其实就是将我们程序的源代码,翻译成CPU能够直接运行的机器代码。
#include <stdio.h>
int add(int a, int b);
int main()
{
printf("Hello, World!\n");
int result = add(5, 5);
return 0;
}
- 比如我们写了一个源文件main.c,里面简单输出了一行文字,并且调用了一个函数add,而这个函数add被定义在另一个源文件math.c中。
int add(int a,int b)
{
return a+b;
}
- 这里我们就可以调用gcc -c来分别编译这两个源文件,需要注意的是,编译永远都是以单个源文件为单位的,在实际开发中,我们通常会将不同功能的代码分散到不同的源文件,一方面方便代码的阅读和维护,同时也提升了软件构建的速度。、
- 比如你修改了一个源文件,那么只需要单独编译它这一个,而不需要浪费时间重新编译整个工程,可以看到在编译之后会生成两个扩展名为.o的文件,它们被称作目标文件(Object File),目标文件是一个二进制的文件,文件格式是ELF(Executable and Linkable Format),linux下所有可执行文件的通用格式,相应的Windows使用的是另一种格式PE,它们虽然互不兼容但在结构上非常相似,都是对二进制代码的一种封装。
- 我们可以在文件头部找到可执行文件的基本信息比如支持的操作系统,机器类型等等。
- 后面是一系列的区块,被叫做sections,里面有我们的机器代码还有程序的数据等等,比如.text是代码区,里面是之前编译好的机器代码,.data是数据区,里面保存了我们初始化的全局变量、局部静态变量等等,后面还有一些其他的区块稍后再说。
- 需要注意的是,目标文件虽然包含了编译之后的机器代码,但它并不能够直接执行,操作系统也不允许你去执行它。
- 因为在编译的过程中,我们用到了尚未定义的函数add,而我们主程序中的add其实只是一句声明而已,它被定义在另一个模块math.c中,这同样也包括我们用到的标准库中的printf,如果我们去查看stdio.h头文件,其中的printf只是一句函数的声明而已,换句话说,我们在编译main.c的时候,编译器是完全不知道printf和add函数的存在的,比如它们位于内存的哪个区块,代码长什么样,都是不知道的,因此编译器只能将这两个函数的跳转地址暂时先设为0,随后在连接的时候再去修正它。
- 比如我们来看一下main.o这个目标文件中的内容,这里的mian是编译之后的主函数代码,左边是机器代码右边是对应的反汇编,可以看到这里的两个call指令,很明显它们分别对应之前调用的printf和add函数,但是你会发现它们的跳转地址都被设成了0,而这里的0会在后面链接的时候被修正。
链接
- 另外为了让链接器能够定位到这些需要被修正的地址,在代码块中我们还可以找到一个重定位表,比如在.text区块中,需要被重定位的两个函数printf和add,它们位于偏移量14和23的位置,后面是地址的类型和长度,这和我们之前看到的机器代码是一一对应的。
- 我们将另一个源文件math.c也编译出来,最后连同main.o一起链接生成一个独立的可执行文件。我们用到的还是gcc命令,后面传入之前编译的这两个目标文件,随后在目录下可以找到生成的可执行文件main,而这个main就可以直接运行了。
- 所以链接其实就是将编译之后的所有目标文件,连同用到的一些静态库、运行时库,组合拼装成一个独立的可执行文件,其中就包括我们之前提到的地址修正,在这个时候,链接器就会根据我们的目标文件或者静态库中的重定位表,找到那些需要被重定位的函数、全局变量,从而修正它们的地址。
- 但如果我们在链接的时候,忘记提供必须的目标文件,比如这里的math.o,由于链接器找不到add函数的实现,于是报错“引用未定义”,或者有的编译器也叫它“符号未定义(undefined symbols)”,意思就是我们用到了add但链接器却无法找到它的定义,因此只能报错。
makefile
- 如果我们每次都手动编译再链接显然不够高效,实际开发也没有人这么做,通常我们都是用各种各样的IDE或者构建工具帮我们自动化了。
- 所以这里引出一种最简单的构建工具makefile(make),可能很多人对它的印象是很古老。但其实makefile除了软件构建之外还有许多其他的奇技淫巧,比如用它来自动生成文档等等,像很多现在的项目也都还在用它,譬如Android OS的构建等等,makefile的核心其实是对“依赖(Dependency)”的管理,比如要构建main则需要main.o和math.o这两个文件,同时执行下面这条链接指令,如果要构建main.o又需要main.c这个文件,同时执行下面这条“编译”指令。
- 然后你会发现,makefile其实就是在定义一颗依赖树,你要构建最上方的这个目标就需要提供下面这些节点的文件,然后这样层层地递归下去。
- 有了makefile以后我们可以调用make命令,后面跟上目标的名称main,它会自动根据我们的“依赖树”递归地去构建这个可执行文件,当然第一次运行由于所有叶子节点都不存在,make会自动构建所有的依赖,包括其中的目标文件,但如果我们再运行一次make由于所有的文件都已经存在并且是最新的,make就不会在重复构建了。
- 此时如果我们再单独修改一下main.c文件,由于main.c只会影响main.o从而影响最后的可执行文件main,所以make只会去重新生成这两个相关的文件,从而避免了其他不必要的文件编译,其实所有的现代化构建工具,都用到了相同的原理——对依赖的管理,只不过加入了一些更实用的功能比如脚本语言的支持,第三方库的管理等等。