C/C++源代码是如何被最终执行的?
C/C++的源程序文件都是程序员按照相关语法和规则编写的。但是这样的程序文件并不能直接被硬件识别和执行。本文将简要描述C/C++的源代码是如何经过转化并最终转变成可以被硬件识别执行的二进制文件的。
C语言是一种结构化的编程语言。与Java、Python编程语言相比,C语言更加接近汇编语言。但是C语言依然算是可以被人类阅读的高级编程语言。它无法被计算机硬件直接认识执行。因此,C语言的源代码的执行需要经过几个步骤。C++其实算是C语言的超集,它是在C语言的基础上增加了额外特性的编程语言,因此过程也是类似的。理解C语言从源代码到最后的执行程序之间的转变过程可以帮助我们更好理解C语言编程。
一、可执行文件(executable file)的理解
在说明C语言的执行过程之前,我们需要理解一下可执行文件。可执行文件通常可以理解成能够直接被计算机硬件所执行的程序文件。它是一种二进制文件。现代的可执行文件大多数都借助操作系统运行(由于很多程序都使用了操作系统提供的接口来使用硬件资源,如内存、CPU等),因此可执行文件通常都是依赖操作系统的文件。当然,也有一些可执行文件可以直接被CPU从硬盘中读取加载执行,这里就不多赘述了。
在windows下的exe文件,Linux下的ELF( Executable and Linking Format)文件(扩展名是o的文件)都是可执行文件。它们都可以被操作系统直接读取执行。那么,这里所说的C语言的执行过程就是C语言是如何从源文件(文本代码)转变成最终的可执行文件的过程。
大体上,C语言的源代码需要经过如下步骤转成可执行的程序:

简而言之就是,C语言的源代码首先需要经过编译(compilation)过程变成对象代码(obj code),然后经过连接器(linker)的连接(linking)过程就能变成可执行文件了。编译作为第一个大阶段,其中还包含了预处理、编译、汇编三个步骤。
接下来我们详细描述这个过程。
二、源文件(source code)的预处理
C/C++的源程序可以理解成文本代码,人们可以通过文本编辑器等工具直接打开查看。例如,如下所示是一个简单的C语言代码。
#include <stdio.h>
void main(void)
{
printf("Hello World!");
}
它被存放在main.c的文件中。这里以c作为扩展名结尾的文件,就是C语言的源程序文件了。这种文件可以直接用文本编辑器打开(如txt/vs code等)。
C语言执行的第一个过程就是预处理。它是由一个预处理器(preprocessor)进行的,主要的作用就是“替换”,包括将注释去掉、将#define定义的变量用实际的值代替等。C预处理器不是编译器的一部分,而是编译过程中的一个独立步骤。简单地说,C语言预处理器只是一个文本替换工具,它指示编译器在实际编译前做必要的预处理。
下图就是我们使用cl.exe预处理之后的上述程序结果:

可以看到,很多内容都被替换了。当然,这个文档很长,我只展示了前面几行。这个.i结尾的文件就是扩展的源代码文件。
三、扩展源代码(expanded source code)的编译
经过上述预处理之后的扩展源代码文件依然是不可以被执行的文件,下一步就是编译(compiling)。这里的编译就是用编译器将源代码文件转变成汇编代码文件。其结果是中间编译输出文件(intermediate compiled output file )。我们知道,早期为了让人能够拜托01组成的完全二进制的机器语言,人们发明了汇编语言,汇编语言的每一个指令都对应着二进制机器代码的一个指令,但是由于是基于英文缩写的字母和十六进制等组成的语言,人们已经能理解代码的含义了。
C语言的编译的第二个阶段就是将扩展源代码转化成汇编代码。注意,这不是必须的步骤。但是转成汇编代码可以帮助我们进行debug与优化。所以大多数编译过程都包含这个步骤。汇编代码的后缀是.s(windows下是asm文件)。下图就是我们cl编译之后生成的汇编文件截图:

汇编代码已经是接近机器语言的代码了,它的代码都对应着机器代码的指令。一般做嵌入式开发的同学们可能了解比较多。但这也是编译过程中一个结果文件。生成这样的文件其实有利于我们知道机器硬件是如何执行程序的,有助于我们优化代码。
四、汇编代码的汇编过程(assembling)
汇编就不多说了,这也是编译过程的最后一个步骤,就是将汇编代码编程二进制代码(.obj文件)了。到这里其实已经算是可执行文件了,但是一般我们认为它属于可重定位文件(relocatable),但是不是可以直接执行的。因为现代代码中都需要使用其它代码提供的能力,所以不仅仅是需要把自己变为可执行的指令,还需要把使用到的其它内容放进来,这就是下一个步骤连接。
五、obj文件的链接
如前所述,obj文件是不可以被直接执行的,还需要有链接器(linker)做链接操作。这是最后一个阶段,在这个阶段,所有的函数调用与它们的定义都被连接起来了。链接器知道所有这些函数在哪里实现。链接器也做了一些额外的工作,它为我们的程序添加了一些额外的代码,这些代码在程序开始和结束时是必需的。例如,有一段代码需要用来设置环境,如传递命令行参数。这项任务可以通过使用size filename.o和size filename轻松验证。通过这些命令,我们知道输出文件是如何从一个对象文件增加到一个可执行文件的。这是因为链接器在我们的程序中添加了额外的代码。
主要来说,所有用C语言编写的程序都使用库函数。这些库函数是预先编译的,这些库文件的目标代码以’.lib’(或’.a’)为扩展名存储。链接器的主要工作是将库文件的目标代码与我们程序的目标代码相结合。有时,当我们的程序引用其他文件中定义的功能时,会出现这种情况;这时,链接器就会起到非常重要的作用。它将这些文件的目标代码连接到我们的程序中。因此,我们得出结论,链接器的工作是将我们程序的目标代码与库文件和其他文件的目标代码连接起来。链接器的输出是可执行文件。可执行文件的名称与源文件相同,只是它们的扩展名不同。在DOS中,可执行文件的扩展名是’.exe’,而在UNIX中,可执行文件可以被命名为’a.out’。例如,如果我们在一个程序中使用printf()函数,那么链接器就会在输出文件中添加其相关代码。
参考文献:
https://www.geeksforgeeks.org/compiling-a-c-program-behind-the-scenes/
https://www.javatpoint.com/compilation-process-in-c
欢迎大家关注DataLearner官方微信,接受最新的AI技术推送
