文章目录
前言
本篇博客只是针对上层应用对编译链接过程进行一个笼统的介绍,让C语言初学者对于编译和链接有一个基本的印象轮廓,具体的内容学校计算机专业会有专门这样一节课,需要学的东西还是很多的哈😘
翻译环境和运行环境
在ANSI C 1的任何一种实现中,存在两个不同的环境
第1种是翻译环境,在这个环境中源代码被转换为可执⾏的机器指令(⼆进制指令)。
第2种是执⾏环境,它⽤于实际执⾏代码。
翻译环境
那翻译环境是怎么将源代码转换为可执⾏的机器指令的呢?这⾥我们就得展开讲解⼀下翻译环境所做的事情。
其实翻译环境是由编译和链接两个⼤的过程组成的,⽽编译⼜可以分解成:预处理(也叫预编译)、编译、汇编三个过程。
编译
⼀个C语⾔的项⽬中可能有多个 .c
⽂件⼀起构建,那多个 .c
⽂件如何⽣成可执⾏程序呢?
-
多个
.c
⽂件单独经过编译器,编译处理⽣成对应的⽬标⽂件。- 在
Windows
环境下的⽬标⽂件的后缀是.obj
, Linux
环境下⽬标⽂件的后缀是.o
- 在
-
多个⽬标⽂件和链接库⼀起经过链接器处理⽣成最终的可执⾏程序。
链接库是指运⾏时库(它是⽀持程序运⾏的基本函数集合)或者第三⽅库
如果再把编译器展开成3个过程,那就变成了下⾯的过程:
编译过程以Linux环境下的gcc为编译器演示
预处理(预编译)
在预处理阶段,源⽂件和头⽂件会被处理成为 .i
为后缀的⽂件。 在 gcc
环境下想观察⼀下,对 test.c
⽂件预处理后的.i
⽂件,命令如下:
gcc -E test.c -o test.i
预处理阶段主要处理那些源⽂件中#
开始的预编译指令。
⽐如:#include
,#define
,处理的规则如下:
- 将所有的
#define
删除,并展开所有的宏定义。 - 处理所有的条件编译指令,如:
#if
、#ifdef
、#elif
、#else
、#endif
。 - 处理
#include
预编译指令,将包含的头⽂件的内容插⼊到该预编译指令的位置。这个过程是递归进⾏的,也就是说被包含的头⽂件也可能包含其他⽂件。 - 删除所有的注释
- 添加⾏号和⽂件名标识,⽅便后续编译器⽣成调试信息等。
- 或保留所有的
#pragma
的编译器指令,编译器后续会使⽤。
经过预处理后的 .i
⽂件中不再包含宏定义,因为宏已经被展开。并且包含的头⽂件都被插⼊到 .i
⽂件中。所以当我们⽆法知道宏定义或者头⽂件是否包含正确的时候,可以查看预处理后的 .i
⽂件来确认。
总之是一些拷贝复制删除等等,都属于文本操作
//演示代码
#include <stdio.h>
#define M 100+200
//这是一条注释
int main()
{
//这也是一条注释
int a = M;
printf("haha\n");
return 0;
}
//在预处理阶段将会按照上述规则,例如将头文件展开,删除注释,宏展开等等
对于这个阶段在接下来会专门详细讲解
编译
编译过程就是将预处理后的⽂件进⾏⼀系列的:词法分析、语法分析、语义分析及优化,⽣成相应的汇编代码⽂件。 编译过程的命令如下:
gcc -S test.i -o test.s
对下⾯代码进⾏编译的时候,会怎么做呢?假设有下⾯的代码:
array[index] = (index+4)*(2+6);
词法分析
将源代码程序被输⼊扫描器,扫描器的任务就是简单的进⾏词法分析,把代码中的字符分割成⼀系列的记号(关键字、标识符、字⾯量、特殊字符等)。
语法分析
接下来语法分析器,将对扫描产⽣的记号进⾏语法分析,从⽽产⽣语法树。这些语法树是以表达式为节点的树。
语义分析
由语义分析器来完成语义分析,即对表达式的语法层⾯分析。编译器所能做的分析是语义的静态分析。静态语义分析通常包括声明和类型的匹配,类型的转换等。这个阶段会报告错误的语法信息。
汇编
汇编器是将汇编代码转转变成机器可执⾏的指令,每⼀个汇编语句⼏乎都对应⼀条机器指令。就是根据汇编指令和机器指令的对照表⼀⼀的进⾏翻译,也不做指令优化。
就是将汇编代码生成二进制指令(机器指令)
汇编的命令如下:
gcc -c test.s -o test.o
链接
链接是⼀个复杂的过程,链接的时候需要把⼀堆⽂件链接在⼀起才⽣成可执⾏程序。
链接过程主要包括:地址和空间分配,符号决议和重定位等这些步骤。
链接解决的是⼀个项⽬中多⽂件、多模块之间互相调⽤的问题。
⽐如:
⼀个C的项⽬中有2个.c
⽂件( test.c
和add.c
),代码如下:
test.c
:
#include <stdio.h>
//test.c
//声明外部函数
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);
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
的地址,但是由于每个⽂件是单独编译的,在编译器编译 test.c
的时候并不知道 Add
函数和 g_val
变量的地址,所以暂时把调⽤ Add
的指令的⽬标地址和 g_val
的地址搁置。
等待最后链接的时候由链接器根据引⽤的符号 Add
在其他模块中查找 Add
函数的地址,然后将 test.c
中所有引⽤到 Add
的指令重新修正,让他们的⽬标地址为真正的 Add
函数的地址
例子:
对于全局变量 g_val
也是类似的⽅法来修正地址。这个地址修正的过程也被叫做:重定位。
总之,这仅仅是⾮常简洁地讲解了⼀个C的程序是如何编译和链接,到最终⽣成可执⾏程序的过程,其实很多内部的细节⽆法展开讲解。
⽐如:⽬标⽂件的格式elf
,链接底层实现中的空间与地址分配,符号解析和重定位等,同时,这里也推荐一本书:《程序员的自我修养》。
运行环境
- 程序必须载⼊内存中。在有操作系统的环境中:⼀般这个由操作系统完成。在独⽴的环境中,程序 的载⼊必须由⼿⼯安排,也可能是通过可执⾏代码置⼊只读内存来完成。
- 程序的执⾏便开始。接着便调⽤
main
函数。 - 开始执⾏程序代码。这个时候程序将使⽤⼀个运⾏时堆栈(
stack
),存储函数的局部变量和返回地址。程序同时也可以使⽤静态(static
)内存,存储于静态内存中的变量在程序的整个执⾏过程 ⼀直保留他们的值。 - 终⽌程序。正常终⽌
main
函数;也有可能是意外终⽌。
预处理(预编译)详解
在上面的翻译环境简单介绍了预处理的一些操作,在接下来将会比较详细的讲解一下:
预定义符号
__FILE__ //进⾏编译的源⽂件
__LINE__ //⽂件当前的⾏号
__DATE__ //⽂件被编译的⽇期
__TIME__ //⽂件被编译的时间
__STDC__ //如果编译器遵循ANSI C,其值为1,否则未定义
在gcc上举例:
int main()
{
printf("%s\n", __FILE__);
printf("%d\n", __LINE__);
printf("%s\n", __DATE__);
printf("%s\n", __TIME__);
printf("%d\n", __STDC__);//在gcc上打印出1,说明gcc完全遵循ANSI C(如果在VS上则是未定义,说明VS上不完全遵循ANSI C)
return 0;
}
#define定义常量
基本语法:
#define name stuff
举个例子:
#define MAX 1000
#define reg register //为 register这个关键字,创建⼀个简短的名字
#define do_forever for(;;) //⽤更形象的符号来替换⼀种实现
#define CASE break;case //在写case语句的时候⾃动把 break写上。
例:
int main()
{
int n = 0;
scanf("%d", &n);
switch (n)
{
case 1:
CASE 2:
CASE 3:
·······
}
return 0;
}//不建议这么搞,可读性差
// 如果定义的 stuff过⻓,可以分成⼏⾏写,除了最后⼀⾏外,每⾏的后⾯都加⼀个反斜杠(续⾏符)
符)。
#define DEBUG_PRINT printf("file:%s\tline:%d\t \
date:%s\ttime:%s\n" ,\
__FILE__,__LINE__ , \
__DATE__,__TIME__ )
在定义标识符的时候,不要在最后加上;
比如:
#difine a 5;
int main()
{
int flag=0;
if(a)//预处理替换后就变为if(5;)
flag=1;
return 0;
}
直接报错
#define定义宏
#define
机制包括了⼀个规定,允许把参数替换到⽂本中,这种实现通常称为宏(macro
)或定义宏 (define macro
)。
下⾯是宏的申明⽅式:
#define name( parament-list ) stuff
其中的 parament-list
是⼀个由逗号隔开的符号表,它们可能出现在stuff
中。
注意:
参数列表的左括号必须与
name
紧邻,如果两者之间有任何空⽩存在,参数列表就会被解释为stuff
的 ⼀部分。
举例:
//实现一个宏,求平方
#define SQUARE(x) x * x
int main
{
int m = SQUARE(5);
return 0;
}
这个宏接收⼀个参数 x
.如果在上述声明之后,把 SQUARE( 5 )
置于程序中,预处理器就会⽤下⾯这个表达式替换上⾯的表达式: 5 * 5
这就是在求5的平方
警告:
这个宏存在一个问题:
问题:
#define SQUARE(x) x * x
int main
{
int m = SQUARE(5+1);
return 0;
}
预处理器替换后:
int main
{
int m =5+1*5+1;
return 0;
}
我们本来要求6的平方,结果替换后先算乘法,算出来为11
改进:
#define SQUARE(x) (x) * (x)
这里我们认识到,定义宏单个参数要加括号
这里还有一个宏:
//写一个代码求一个数的2倍
#define DOUBLE(x) (x)+(x)
这个宏也存在一个问题:
问题:
//写一个代码求一个数的2倍
#define DOUBLE(x) (x)+(x)
int main()
{
int ret = 10 * DOUBLE(5);
return 0;
}
预处理器替换后:
int main()
{
int ret = 10 *(5)+(5);
return 0;
}
我们本来要求10*10,结果算出来是55
改进:
#define DOUBLE(x) ((x)+(x))
这里我们认识到,宏定义参数列表外部也需要加括号
综上:
所以⽤于对数值表达式进⾏求值的宏定义都应该⽤这种⽅式加上括号,简单来说就是内部括号+外部括号,避免在使⽤宏时由于参数中的操作符或邻近操作符之间不可预料的相互作⽤。
带有副作用的宏参数
当宏参数在宏的定义中出现超过⼀次的时候,如果参数带有副作⽤,那么你在使⽤这个宏的时候就可能出现危险,导致不可预测的后果。副作⽤就是表达式求值的时候出现的永久性效果。
例如:
x+1;//不带副作⽤
x++;//带有副作⽤
MAX宏可以证明具有副作⽤的参数所引起的问题。
#define MAX(a, b) ( (a) > (b) ? (a) : (b) )
...
x = 5;
y = 8;
z = MAX(x++, y++);
printf("x=%d y=%d z=%d\n", x, y, z);//输出的结果是什么?
这⾥我们得知道预处理器处理之后的结果是什么:
z = ( (x++) > (y++) ? (x++) : (y++));
所以输出的结果:
x=6;
y=10;
z=9;
可以看到,在使用++
操作符时,让x和y的值发生了多次改变,从而出现了不可预料的结果,这就是带有副作用的宏参数
宏替换的规则
在程序中扩展#define
定义符号和宏时,需要涉及⼏个步骤。
- 在调⽤宏时,⾸先对参数进⾏检查,看看是否包含任何由
#define
定义的符号。如果是,它们⾸先被替换。 - 替换⽂本随后被插⼊到程序中原来⽂本的位置。对于宏,参数名被他们的值所替换。
- 最后,再次对结果⽂件进⾏扫描,看看它是否包含任何由
#define
定义的符号。如果是,就重复上述处理过程。
注意:
- 宏参数和
#define
定义中可以出现其他#define
定义的符号。但是对于宏,不能出现递归。 - 当预处理器搜索
#define
定义的符号的时候,字符串常量的内容并不被搜索。
例子:
#include <stdio.h>
#define M 100
#define N M+2
#define MAX(x, y) ((x)>(y)?(x):(y))//不能出现递归
int main()
{
int m = MAX(M, 15);//两次替换
printf("MAX(M, 15)");//不被搜索
return 0;
}
宏和函数的对比
宏通常被应⽤于执⾏简单的运算。
⽐如在两个数中找出较⼤的⼀个时,写成宏,更有优势⼀些。
//宏
#define MAX(x, y) ((x)>(y)?(x):(y))
//函数
int Max(int x, int y)
{
return x > y ? x : y;
}
int main()
{
int a = 10;
int b = 20;
int m = MAX(10, 20);
//直接替换:int m = ((10) > (20) ? (10) : (20));
printf("%d\n", m);
int m = Max(10, 20);
printf("%d\n", m);
return 0;
}
那为什么不⽤函数来完成这个任务? 原因有⼆:
- ⽤于调⽤函数和从函数返回的代码可能⽐实际执⾏这个⼩型计算⼯作所需要的时间更多。所以宏⽐函数在程序的规模和速度⽅⾯更胜⼀筹。
二者在执行核心运算指令差不多,但函数多了调用准备工作和返回的指令
- 更为重要的是函数的参数必须声明为特定的类型。所以函数只能在类型合适的表达式上使⽤。反之, 这个宏怎可以适⽤于
整形
、⻓整型
、浮点型
等可以⽤于>
来⽐较的类型。宏的参数是类型⽆关的。
宏有时候可以做函数做不到的事情。
⽐如:宏的参数可以出现类型,但是函数做不到。
#define MALLOC(n, type) (type*)malloc(n*sizeof(type))
int main()
{
//int *p = (int*)malloc(10*sizeof(int));
//更加方便如下:
int *p = MALLOC(10, int);
//int* p = (int*)malloc(10 * sizeof(int));
return 0;
}
但是但是:
和函数相⽐宏的劣势:
- 每次使⽤宏的时候,⼀份宏定义的代码将插⼊到程序中。除⾮宏⽐较短,否则可能⼤幅度增加程序的⻓度。
- 宏是没法调试的。 (调试时程序已经运行起来了,但宏在编译阶段的预编译就完成了)
- 宏由于类型⽆关,也就不够严谨。
- 宏可能会带来运算符优先级的问题,导致程序求值等计算容易出现错。
综上我们来对比一下:
写在最后
关于编译链接具体过程十分复杂,本篇只是笼统地介绍了一下,在下一篇博客中将会把预处理阶段其它的知识进行比较详细的介绍,敬请期待喔😘
以上就是编译和链接以及预处理介绍(上篇)内容啦,各位大佬有什么问题欢迎在评论区指正,您的支持是我创作的最大动力!❤️