我们从一段简单的C语言源代码 hello.c 出发:
#include
int main()
{
printf("Hello World!\n");
return 0;
}
在 Linux 环境下, gcc 编译器编译之后生成可执行文件 a.out ,执行可执行文件在终端打印出 Hello World!
。通常情况下,我们都是通过 gcc 编译器直接一步得到可执行文件,但实际上从源程序文件到可执行代码经历了四个阶段:预处理、编译、汇编和链接,如下图所示。
图片摘自:深度解析程序从编译到运行
下面我们通过控制编译选项来观察这四个阶段:
gcc hello.c 源程序代码直接生成可执行文件a.out
gcc -E hello.c -o hello.i 生成预处理后的代码(预处理器cpp)
gcc –S hello.i -o hello.s 生成汇编代码(编译器cc1)
gcc –c hello.s -o hello.o 生成目标代码(汇编器as)
gcc hello.o 生成可执行文件(链接器器ld)
Linux环境下,一段C语言源代码,由源文件到可执行文件并运行。从内存角度的看,这一系列的过程在内存中是发生怎样的变化的呢?这部分参考文章:C语言—内存的管理和释放
接下来我们仔细分析程序编译到运行的这四个阶段。
预编译过程主要处理那些源代码文件中的以“#”开始的预编译指令,包括宏定义指令、条件编译指令和头文件包含指令。
#define Name TokenString
等。 预编译所要做的是将程序中的所有Name用TokenString替换。#ifdef,#ifndef,#else,#elif,#endif
等。这些伪指令的引入使得程序员可以通过定义不同的宏来决定编译程序对哪些代码进行处理。#include ,#include "animal.h>
等。 将所有的 include 语句替换为头文件的实际内容。包含可以是多重的,也就是说一个被包含的文件中还可以包含其他文件。同时预处理过程还会删除注释行,添加行号和文件名标识,便于编译时编译器产生编译错误或警告时能够显示行号。
注: #include
<>:表示系统自带的头文件,编译器先从系统目录下开始搜索,然后再搜索PATH环境变量所列出的目录。
" ": 表示用户自定义的头文件,编译器从当前目录搜索,然后再搜索系统目录和PATH环境变量所列出的目录。
下面我们通过具体的执行过程来加深理解,还是以上面hello.c代码为例。
执行 gcc -E hello.c -o hello.i
命令,生成 hello.i 文件,这仍然是一个文本文件,我们通vim来查看:
发现6行的 hello.c 源代码经过预处理之后,生成的 hello.i 文本文件有732行,这是因为
预处理除了使用gcc -E命令之外,还可以直接调用 cpp 工具,执行命令:cpp hello.c -o hello.i
,执行结果是一样的。
最后总结一下,预处理过程所做的事情:
经过预编译得到的 .i 文本文件中,只有常量;如数字、字符串、变量的定义,以及C语言的关键字,如:main, if , else , for , while , { , } , + , - , * , \ 等等,接下来进入编译阶段。
编译是将文本文件转换成汇编代码的过程,具体的步骤主要有:词法分析 -> 语法分析 -> 语义分析及相关的优化 -> 中间代码生成 -> 目标代码生成(.s)。
在这个阶段中,gcc首先要检查代码的规范性、是否有语法错误等,以确定代码的实际要做的工作,在检查无误后,gcc把代码翻译成汇编语言。下面我们使用”gcc -S”选项来进行查看,该选项只进行编译而不进行汇编,生成汇编代码(.s)。
执行命令 gcc -S hello.i -o hello.s
,通过vim查看汇编代码 hello.s 文件:
我们发现 hello.s 文件是一个 44 行汇编代码文件。
除了使用 gcc -S 命令之外,还可以直接调用 cc1工具完成这个过程。本人在 gcc version 9.3.0 (Ubuntu 9.3.0-17ubuntu1~20.04) 环境下没有找到 cc1 命令,这里就不赘述了。
经过优化得到的汇编代码必须经过汇编过程转换成相应的二进制机器指令,方可能被机器执行。汇编阶段是把编译阶段生成的汇编语言代码翻译成二进制目标代码(也就是二进制机器指令)。对于被编译系统处理的每一个C语言源程序,都将最终经过这一处理而得到相应的目标文件,目标文件中所存放的也就是与源程序等效的目标的机器语言代码。
汇编器(as)将 hello.s 翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在目标文件 hello.o 中。
执行命令 gcc -c hello.s -o hello.o
,生成hello.o文件。hello.o文件是一个二进制文件,它的字节编码是机器语言指令而不是字符。
除了使用 gcc -c 命令之外,还可以直接调用 as工具 完成这个过程,执行命令:as hello.s -o hello.o
。
如果在文本编译器中打开hello.o文件,看到的将是一堆乱码,如下:
我们可以通过 objdump、readelf 等工具来查看 hello.o 文件的内容,执行命令 objdump -h hello.o
。(objdump、readelf 工具的使用参考文章:Linux开发工具 — readelf、objdump、hexdump)
我们发现目标文件是由段组成的,通常一个目标文件中至少有两个段:
UNIX环境下主要有三种类型的目标文件:
汇编过程生成的 .o 文件是第一种类型的目标文件—可重定位文件。对于后两种目标文件还需要经过链接过程才能得到。
关于目标文件还有很多更深层次的理解,因为本篇文章主要是探讨编译运行的四个阶段,对于目标文件就不进行深层次的讨论了,关于目标文件的深刻理解参考文章:
深度解析程序从编译到运行
从hello world 说程序运行机制
由汇编程序生成的目标文件并不能立即就被执行,其中可能还有许多没有解决的问题。
例如,某个源文件中的函数可能引用了另一个源文件中定义的某个符号(如变量或者函数调用等);在程序中可能调用了某个库文件中的函数,等等。所有的这些问题,都需要经链接程序的处理方能得以解决。
链接程序的主要工作就是将有关的目标文件彼此相连接,即将一个文件中引用的符号同该符号在另外一个文件中的定义连接起来,使得所有的这些目标文件成为一个能够被操作系统装入执行的统一整体。
例如:hello 程序调用了一个 printf 函数,它是每个C编译器都会提供的标准C库中的一个函数,printf 函数存在于一个名为 printf.o 的单独预编译好了的标准文件中,而这个文件必须以某种方式合并到我们的hello.o 程序中,链接器(ld)就负责处理这种合并,结果就得到 hello 文件,他是一个可执行目标文件(简称:可执行文件),可以被加载到内存中,由系统执行。
链接处理分为两种:静态连接和动态连接。
①静态链接:在程序执行之前就完成链接工作,也就是等链接完成后文件才能执行。但是这有一个明显的缺点,比如说库函数,如果文件A 和文件B都需要用到某个库函数,链接完成后他们连接后的文件中都有这个库函数,当A和B同时执行时,内存中就存在该库函数的两份拷贝,这无疑浪费了存储空间。当规模扩大的时候,这种浪费尤为明显。静态链接还有不容易升级等缺点。为了解决这些问题,现在的很多程序都用动态链接。
②动态链接:和静态链接不一样,动态链接是在程序执行的时候才进行链接。也就是当程序加载执行的时候。还是上面的例子 ,如果A和B都用到了库函数 fun(),A和B执行的时候内存中就只需要有 fun() 的一个拷贝。
我们在面试的时候,也会被问到静态连接和动态链接相关知识,因此,需要掌握这方面知识,同时还应熟悉静态库和动态库的制作以及使用
致谢!
本文很多内容取自他人文章,再经过自己的整理和修改。感谢这些文章的作者,参考文章如下:
深度解析程序从编译到运行
C/C++程序编译过程详解
从hello world 说程序运行机制