预编译和源码-C程序(源代码)如何在硬件上运行?看这里

C语言的编译链接过程就是将我们编译的c程序(源代码)转换成可以在硬件上运行的程序(可执行代码),需要进行编译链接。 编译是将文本源代码翻译成机器语言目标文件的过程。链接是将目标文件、操作系统启动代码和使用的库文件组织起来最终生成可执行代码的过程。

从图中可以看出,整个代码的编译过程分为两个过程:编译和链接。 编译对应图中大括号内的部分,剩下的就是链接过程。

流程示意图如下:

编译过程

编译过程可以分为两个阶段:编译和汇编。

编译

编译就是读取源程序(字符流),从词法和句法上进行分析,将中间语言指令转换成功能等价的汇编代码。 源文件的编译过程包括两个主要阶段:

第一阶段是预处理阶段,发生在即将到来的编译阶段之前。 预处理阶段根据已放置在文件中的预处理指令更改源文件的内容。 例如,#include 指令是一个预处理指令,它将头文件的内容添加到 .cpp 文件中。 这种在编译前更改源文件的形式提供了极大的灵活性,以适应不同计算机和操作系统环境的限制。 一种环境所需的代码可能与另一种环境所需的代码不同,因为可用的硬件或操作系统不同。 在很多情况下,可以将不同环境的代码放在同一个文件中,然后在预处理期间更改代码以适应当前环境。

主要处理以下几个方面:

(1)宏定义指令,如#define ab

对于这些指令,预编译要做的就是将程序中的所有 a 替换为 b,但作为字符串常量的 a 不会被替换。 还有#undef,它将取消宏的定义,以便后续出现的字符串将不再被替换。

(2)条件编译指令,如#ifdef、#ifndef、#else、#elif、#endif等。

这些指令的引入使程序员能够通过定义不同的宏来决定编译器处理哪些代码。 预编译器会根据相关文件过滤掉这些不必要的代码。

(3)头文件包含指令,如#include 'FileName'或#include等。

在头文件中预编译和源码,通常使用#define指令定义大量的宏(最常见的是字符常量),并且还包括各种外部符号的声明。 使用头文件的主要目的是使多个不同的 C 源程序可以使用各个定义。 因为在需要使用这个定义的C源程序中,只需要添加一个#include语句即可,不需要在这个文件上重复这个定义。 预编译器会将头文件中的所有定义添加到它形成的输出文件中,以供编译器处理。 C源程序中包含的头文件可以由系统提供。 这些头文件通常放在/usr/include目录下。 使用尖括号 () 将它们#include 到程序中。 另外,开发者还可以定义自己的头文件。 这些文件通常放置在与c源程序相同的目录中。 此时#include中应使用双冒号('')。

(4)特殊符号。 预编译器可以识别一些特殊符号。

例如,源程序中出现的LINE标记将被解释为当前行号(十进制数),FILE将被解释为当前编译的C源程序的名称。 预编译器会将源程序中出现的字符串替换为适当的值。

预编译器所做的基本上就是“替换”源程序。 进行此替换后,将生成一个没有宏定义、没有条件编译指令、也没有特殊符号的输出文件。 该文件的含义与未预处理的源文件相同,但内容不同。 接下来,这个输出文件被翻译成机器指令作为编译器的输出。

在第二阶段编译优化时,预编译得到的输出文件只包含常量; 如数字、字符串、变量定义、C语言中的关键字,如main、if、else、for、while、{、}、+、-、*、等。

编译器需要做的是通过词法分析和句子分析,在确认所有指令符合语法规则后,将其翻译为等效的中间代码表示或汇编代码。

优化处理是编译系统中比较不起眼的技术。 它涉及到的问题不仅与编译技术本身有关,而且与机器的硬件环境有很大关系。 优化的一部分是中间代码的优化。 此优化与特定计算机无关。 另一种优化主要是针对目标代码的生成。

对于前者的优化,主要工作是删除公共表达式、循环优化(代码提取、强度弱化、改变循环控制条件、已知量的合并等)、复制传播、删除无用的形参等。

后一类优化与机器的硬件结构密切相关。 最重要的考虑是如何充分利用机器各个硬件寄存器中存储的相关变量的值来减少对显存的访问次数。 此外,如何根据机器硬件执行指令(如流水线、RISC、CISC、VLIW等)的特点对指令进行一些调整,使目标代码更短、执行效率更高也是一个重要的研究课题。

汇编

汇编实际上是指将汇编语言代码翻译成目标机器指令的过程。 对于翻译系统处理后的每一个C语言源程序,通过该处理最终都会得到对应的目标文件。 目标文件中存储的是相当于源程序的对象的机器语言代码。 目标文件由段组成。 通常目标文件中至少有两个部分:

代码段:该段主要包含程序指令。

该段通常可读且可执行,但通常不可写。

数据段:主要存放程序中用到的各种全局变量或静态数据。 一般来说,数据段是可读、可写、可执行的。

UNIX 环境中的目标文件主要分为三种类型:

(1) 可重定位文件

它包含适合与其他目标文件链接以创建可执行文件或共享目标文件的代码和数据。

(2) 共享目标文件

该文件存储适合在两种上下文中链接的代码和数据。 首先是链接器可以将其与其他可重定位文件和共享目标文件一起处理以创建另一个目标文件; 第二是动态链接器可以将其与另一个可执行文件和其他共享对象文件组合在一起,它们创建一个进程映像。

(3) 可执行文件

它包含一个可由操作系统通过创建进程来执行的文件。 汇编器生成的实际上是第一种类型的目标文件。 对于后两者,需要进行一些其他处理才能获得它们。 这是链接器的工作。

连接过程

汇编器生成的目标文件不能立即执行,可能存在很多未解决的问题。

例如,一个源文件中的函数可能引用另一个源文件中定义的符号(例如变量或函数调用等); 库文件中的函数可以在程序中调用,等等。 所有这些问题都需要链接器来解决。

链接器的主要工作是将相关的目标文件相互链接起来,并将一个文件中引用的符号与另一个文件中符号的定义正式链接起来,使所有这些目标文件成为一个文件。 成为一个统一的整体。

根据开发者指定的同一库函数的链接形式不同,链接处理可以分为两种:

(1)静态链接

在这些链接形式中,函数的代码将从其所在的静态链接库复制到最终的可执行程序中。 这样,程序执行时这段代码就会被放入进程的虚拟地址空间中。 静态链接库实际上是目标文件的集合,每个目标文件都包含库中一个或一组相关函数的代码。

(2)动态链接

在这种形式中,函数代码被放置在称为动态链接库或共享对象的目标文件中。 链接器此时所做的就是在最终的可执行程序中记录共享对象的名称和少量其他注册信息。 当这个可执行文件被执行时,动态链接库的全部内容都会在运行时被映射到相应进程的虚拟地址空间中。 动态链接器会根据可执行程序中记录的信息找到相应的功能代码。

对于可执行文件中的函数调用,可以分别使用动态链接或静态链接。 使用动态链接可以使最终的可执行文件更短,并且当共享对象被多个进程使用时可以节省一些显存,因为共享对象的代码只需要在显存中保存一份。 但这并不一定意味着使用动态链接就优于使用静态链接。 在某些情况下,动态链接可能会导致一些性能损害。

我们在Linux中使用的gcc编译器就是将以上几个过程捆绑在一起,使得用户只需一条命令即可完成编译工作。 这确实方便了编译工作,但是对于初学者了解编译过程非常不利。 下图是gcc代理的编译过程:

从上图可以看出:

预编译

将 .c 文件转换为 .i 文件

使用的gcc命令是:gcc –E

对应预处理命令cpp

编译

将 .c/.h 文件转换为 .s 文件

使用的gcc命令是:gcc –S

对应编译命令cc –S

汇编

将 .s 文件转换为 .o 文件

使用的gcc命令是:gcc –c

对应的汇编命令为

关联

将.o文件转换为可执行程序

使用的gcc命令是:gcc

对应的链接命令是ld

综上所述,编译过程由前面的四个过程组成:预编译、编译、汇编、链接。 了解这四个过程所做的工作有助于我们理解头文件、库等的工作过程预编译和源码,并且清楚地了解编译和链接过程也可以帮助我们在编程时定位错误,并尽量调动编译器在编程时检查错误会有很大的帮助。