【C语言】深入理解编译与链接过程
文章目录
- 一、程序的两种环境
- 二、翻译环境:从源代码到可执行程序的蜕变
- 2.1 多文件项目的构建流程
- 2.2 编译的三个步骤
- 2.2.1 预处理(预编译)
- 2.2.2 编译
- 2.2.3 汇编
- 2.3 链接
- 三、运行环境
一、程序的两种环境
在ANSI C的任何一种实现中,都存在两个不同的环境,它们共同支撑着C语言程序的生命周期。
- 翻译环境:这是源代码被转换为可执行的机器指令(二进制指令)的地方。我们编写的
.c
源文件就是在这个环境中经历一系列处理,最终变成能被计算机识别的二进制代码。 - 执行环境:顾名思义,它是用于实际执行代码的环境。当翻译环境生成可执行程序后,就会在执行环境中运行,产生我们需要的结果。
举个简单的例子,假设有test1.c
、test2.c
、test3.c
三个源文件,它们会先在翻译环境中经过编译、链接等过程生成可执行程序,然后这个可执行程序在运行环境中输出结果。
二、翻译环境:从源代码到可执行程序的蜕变
翻译环境的核心任务是将源代码转换为可执行的机器指令,它由编译和链接两个大的过程组成,而编译又可以进一步分解为预处理(预编译)、编译、汇编三个步骤。
2.1 多文件项目的构建流程
一个C语言项目往往包含多个.c
文件,这些文件共同协作才能完成特定的功能。那么,多个.c
文件是如何一步步生成可执行程序的呢?
- 多个
.c
文件会单独经过编译器的处理,生成对应的目标文件。这里需要注意的是,在Windows环境下,目标文件的后缀是.obj
;而在Linux环境下,目标文件的后缀是.o
。 - 随后,这些多个目标文件和链接库一起经过链接器的处理,最终生成可执行程序。链接库指的是运行时库(支持程序运行的基本函数集合)或者第三方库。
比如,test.c
经过编译器处理生成test.obj
,add.c
经过编译器处理生成add.obj
,然后这些.obj
文件与链接库在链接器的作用下生成可执行程序。
2.2 编译的三个步骤
如果我们把编译器的工作展开,会得到更细致的三个过程:
2.2.1 预处理(预编译)
预处理阶段,源文件(.c
为后缀)和头文件(.h
为后缀)会被处理成为以.i
为后缀的中间文件。
在gcc环境下,我们可以使用如下命令观察对test.c
文件预处理后的.i
文件:
gcc -E test.c -o test.i
预处理阶段主要处理源文件中以#
开始的预编译指令,具体规则如下:
- 将所有的
#define
删除,并展开所有的宏定义。 - 处理所有的条件编译指令,如
#if
、#ifdef
、#elif
、#else
、#endif
。 - 处理
#include
预编译指令,将包含的头文件的内容插入到该预编译指令的位置。这个过程是递归进行的,也就是说被包含的头文件也可能包含其他文件。 - 删除所有的注释。
- 添加行号和文件名标识,方便后续编译器生成调试信息等。
- 保留所有的
#pragma
的编译器指令,供编译器后续使用。
经过预处理后的.i
文件中不再包含宏定义,因为宏已经被展开,并且包含的头文件都被插入到.i
文件中。所以当我们不确定宏定义或者头文件是否包含正确时,可以查看预处理后的.i
文件来确认。
2.2.2 编译
编译过程是将预处理后的.i
文件进行一系列的词法分析、语法分析、语义分析及优化,最终生成相应的汇编代码文件(.s
为后缀)。
在gcc环境下,编译过程的命令如下:
gcc -S test.i -o test.s
我们以一段简单的代码array[index] = (index+4)*(2+6);
为例,来看看编译过程具体做了什么。
- 词法分析:源代码程序被输入扫描器,扫描器会把代码中的字符分割成一系列的记号,这些记号包括关键字、标识符、字面量、特殊字符等。上面的程序经过词法分析后,会得到16个记号,比如
array
(标识符)、[
(左方括号)、index
(标识符)、]
(右方括号)、=
(赋值)等。 - 语法分析:语法分析器会对扫描产生的记号进行语法分析,从而产生语法树。这些语法树是以表达式为节点的树。对于上面的代码,会生成以赋值表达式为根节点,下标表达式和乘法表达式为子节点的语法树,下标表达式又包含
array
标识符和index
标识符,乘法表达式则由两个加法表达式组成等。 - 语义分析:语义分析器完成语义分析,即对表达式的语法层面进行分析。编译器所能做的是语义的静态分析,通常包括声明和类型的匹配、类型的转换等。这个阶段会报告错误的语法信息。比如在上面的代码中,会对每个标识符和表达式进行类型标识,确认
array
是整型数组,index
是整型,各个表达式的结果也是整型等。
经过这些步骤后,就会生成汇编代码文件。
2.2.3 汇编
汇编器的作用是将汇编代码(.s
为后缀)转变为机器可执行的指令(目标文件,.o
或.obj
为后缀)。每一个汇编语句几乎都对应一条机器指令,汇编过程就是根据汇编指令和机器指令的对照表一一进行翻译,不做指令优化。
在gcc环境下,汇编的命令如下:
gcc -c test.s -o test.o
2.3 链接
链接是一个复杂的过程,它需要把多个目标文件和链接库链接在一起,最终生成可执行程序。链接过程主要包括地址和空间分配、符号决议和重定位等步骤,其解决的是一个项目中多文件、多模块之间互相调用的问题。
我们以一个具体的例子来理解链接的作用。假设有test.c
和add.c
两个.c
文件:
test.c
中的代码如下:
#include <stdio.h>
// 声明外部函数
extern int Add(int x, int y);
// 声明外部的全局变量
extern int g_val;int main()
{int a = 10;int b = 20;int sum = Add(a, b);printf("%d\n", sum);printf("g_val=%d\n", g_val);return 0;
}
add.c
中的代码如下:
int g_val = 2022;int Add(int x, int y)
{return x + y;
}
我们知道,每个源文件都会单独经过编译器处理生成对应的目标文件,即test.c
生成test.o
,add.c
生成add.o
。在test.c
中,我们使用了add.c
中的Add
函数和g_val
变量。
但在编译器编译test.c
的时候,它并不知道Add
函数和g_val
变量的地址,所以会暂时把调用Add
的指令的目标地址和g_val
的地址搁置。直到最后链接的时候,链接器会根据引用的符号Add
在其他模块(这里就是add.o
)中查找Add
函数的地址,然后将test.c
中所有引用到Add
的指令重新修正,让它们的目标地址为真正的Add
函数的地址,对于全局变量g_val
也是用类似的方法来修正地址。这个地址修正的过程就被叫做重定位。
三、运行环境
当可执行程序生成后,就会进入运行环境执行。程序的运行过程大致如下:
- 程序载入内存:在有操作系统的环境中,一般由操作系统完成程序的载入;在独立的环境中,程序的载入必须由手工安排,也可能是通过可执行代码置入只读内存来完成。
- 开始执行程序:程序执行便开始,接着会调用
main
函数。 - 执行程序代码:此时程序将使用一个运行时堆栈(stack),存储函数的局部变量和返回地址。同时,程序也可以使用静态(static)内存,存储于静态内存中的变量在程序的整个执行过程中一直保留它们的值。
- 程序终止:程序可能正常终止于
main
函数,也可能意外终止。
以上就是C语言程序从源代码到运行结果所经历的翻译环境和运行环境的全过程。当然,其中还有很多内部细节,如目标文件的格式elf
、链接底层实现中的空间与地址分配等,如果想要深入了解,可以阅读《程序的自我修养》一书。希望通过今天的讲解,能让你对C语言程序的编译和链接过程有更清晰的认识。