文章目录
一、翻译环境和运行环境
在 ANSI C 的任何⼀种实现中,存在两个不同的环境,如下:
-
翻译环境:在翻译环境中,会通过编译和链接两个大步骤,其中编译又分为了预处理(预编译)、编译和汇编,将源代码转换为可执⾏的机器指令(⼆进制指令),生成可执行程序
-
运行环境:即执行环境,在运行环境中会执行可执行程序,并输出结果
如下图:
接下来我们就来学习在翻译环境和运行环境中具体会做些什么
二、翻译环境
上面讲到了,翻译环境是用来将源代码转换为可执⾏的机器指令(⼆进制指令),生成可执行程序的,那么它到底是怎么将源代码转换成可执行的机器指令,又是怎么把机器指令生成可执行程序呢?我们一起来学习一下
翻译环境是由编译和链接两个⼤的过程组成的,⽽编译⼜可以分解成:预处理(有些书也叫预编译)、编译、汇编三个过程,接下来我们就来学习它们
1.编译
编译要完成的就是将我们的源代码转换成可执行的机器指令,如图:
编译器就可以实现编译的功能,也就包括了预处理,编译,汇编这三个操作,我们学习编译也就是学习这三个操作的过程
由于VS是一个高度集成的开发环境,它已经把编译这样的细节隐藏起来了,在VS中,我们只需要按下ctrl+f5,那么VS就会一下就帮我们把编译、链接和执行这三个动作一起完成了,瞬间就可以看到结果
所以在VS中我们无法看到.c的源文件编译和链接的完整过程,这个时候我们就可以借助其它的编译器,在下文中就是以gcc为例进行整个编译链接的讲解
预处理
预处理又称预编译,在预处理阶段,后缀为.c的文件将会被处理为.i的文件,如test.c经过预处理后就会变成test.i
预处理阶段要做的事主要有以下几点:
- 将所有的 #define 删除,并展开所有的宏定义,比如使用宏定义了一个常量,我们一般会这样写:
//使用宏定义了一个常量
#define N 100
//使用宏
int arr[N];
那么经过预处理之后,#define N 100这条语句就会被删除,并且这个宏定义将会被展开,在这里就是将所有N替换成100,如下:
//预处理后,宏定义语句被删除
//展开宏定义,在这里就是将N替换成100
int arr[100];
- 处理所有的条件编译指令,如: #if、#ifdef、#elif、#else、#endif等等,在下一篇预处理详解我们会讲到,这里简单介绍一下
- 处理#include 预编译指令,将包含的头⽂件的内容插⼊到该预编译指令的位置,比如包含头文件stdio.h,那么就会将头文件stdio.h中的所有内容插入到原位置
虽然VS不会生成.i的文件,但是我们还是可以看到头文件的内容,首先使用#include包含stdio.h,然后使用ctrl+单击鼠标,就可以看到stdio.h这个头文件的内容,有两千多行的代码,在预处理后,就会全部插入到我们的源文件中来 - 经过预处理后,会删除所有的注释,所以我们写了注释才不会影响代码的运行,因为在我们正在编译前,就已经把它删除了
- 添加⾏号和⽂件名标识,⽅便后续编译器⽣成调试信息等
- 保留所有的#pragma的编译器指令,它可以保证我们不重复包含头文件
经过预处理后的 .i ⽂件中不再包含宏定义,因为宏已经被展开,并且包含的头⽂件都被插⼊到 .i⽂件中,当我们⽆法知道宏定义或者头⽂件是否包含正确的时候,可以查看预处理后的 .i ⽂件来确认
预处理这部分内容还有许多的知识点需要我们掌握,这里就不展开讲了,在下一篇文章,我们会详细讲解预处理的各种指令
编译
当我们进行预处理后,就来到了编译阶段,编译过程就是将预处理后的⽂件进⾏⼀系列的:词法分析、语法分析、语义分析及优化,⽣成相应的汇编代码⽂件
我们现在就用一句代码为例,看看词法分析、语法分析、语义分析的大致实现思路,如下:
arr[index] = (index+4)*(2+6);
-
词法分析:首先代码就来到了词法分析,这个阶段会将源代码程序输⼊扫描器,扫描器的任务就是简单的进⾏词法分析,把代码中的字符分割成⼀系列的记号(关键字、标识符、字⾯量、特殊字符等)
上⾯程序进⾏词法分析后得到了16个记号,然后就会生成一个记号表,如下图:
在这个阶段会简单的标记每个记号,然后生成一个记号表,这个符号表在后面链接的地方还会用到,我们到时候会再来说它的另一个作用 -
语法分析:来到语法分析阶段后,会将源代码放入语法分析器,将对扫描产⽣的记号进⾏语法分析,从⽽产⽣语法树,这些语法树是以表达式为节点的树,如图:
在这个阶段,我们代码的意思基本上就明确了,相当于在语法分析阶段,会把要组合的记号组合起来,明确这些记号的基本含义
并且在图上我们也可以看出来,这颗语法树的节点是一个又一个的表达式组成,如果在这个时候出现简单的语法错误就可以发现,比如少写一个括号,就不能像这样构成一个以表达式为节点的语法树,程序就可能会报错 -
语义分析:经过词法分析和语法分析后,由语义分析器来完成语义分析,即对表达式的具体语法层⾯的分析
编译器所能做的分析是语义的静态分析,静态语义分析通常包括声明和类型的匹配,类型的转换等,这个阶段会报告错误的语法信息,如下图:
在语义分析这个阶段就能基本明确程序的语法含义了,如明确了类型、类型的转换等信息,而语法分析中只是对记号表中的记号进行了组合和简单的翻译
在这个阶段已经可以判断表达式之间的关系了,比如整型加整型是整型,整型赋值给整型等等,并且在这个阶段可以找出语法错误
比如在赋值表达式的左边算出的结果是一个浮点型,而左边算出来了的却是一个整型,那么就会进行强制类型转换,如果表达式左边是整型,而右边是结构体,就会报错
而且最关键的一点是,我们通过语义分析已经知道了代码的含义,那么把它翻译成汇编代码也不是难事了,所以在这个阶段会正在将源代码翻译成汇编代码,并做相关的优化
汇编
经过预处理和编译阶段后就来到了汇编阶段,在编译阶段的语义分析我们就把源代码翻译成了汇编代码,而在汇编阶段做的事情就是将翻译过来的汇编代码再次转换为计算机可以识别的机器指令(二进制指令)
在翻译时,每⼀个汇编语句⼏乎都对应⼀条机器指令,在翻译期间也不会对代码做什么优化,只是根据汇编指令和机器指令的对照表⼀⼀的进⾏翻译,最终将汇编代码转换成了机器指令,生成一个.obj的目标文件
2.链接
链接是⼀个复杂的过程,链接的时候需要把⼀堆⽂件链接在⼀起才⽣成可执⾏程序,链接过程主要包括:地址和空间分配,符号决议和重定位等这些步骤,那么我们为什么要使用链接这一个步骤呢?
链接解决的是⼀个项⽬中多⽂件、多模块之间互相调⽤的问题,比如在一个项目中有两个.c文件,代码如下:
add.c:
//定义一个函数add
int add(int x, int y)
{
return x + y;
}
test.c
#include <stdio.h>
int main()
{
int a = 3;
int b = 2;
int ret = add(a, b);
printf("%d\n", ret);
return 0;
}
当我们实现好这两个文件后,直接运行后发现出现了错误,我们来看看具体报错:
它说函数add未定义,a是没有声明的标识符,这是为什么呢?这就要涉及到链接了,我们在编译阶段会将我们的源代码翻译成机器指令,生成后缀名为.obj的目标文件,但是我们要注意的是,编译是针对于单个文件的
什么意思呢?就是一个.c的文件生成一个.obj的目标文件,如果有多个.c的文件则生成多个.obj的文件,它们之间互不影响,所以如果我们想要一个文件中的某个函数在另一个文件中使用就做不到,我们可以画图理解,如图:
从图片中我们很明显地看出来了,多个.c文件生成多个.obj文件,之间互不影响,所以如果我们要让一个文件中的某个函数在另一个文件中使用就必须通过链接来完成,接下来我们就一起来学习链接的过程
在链接过程中,需要用到之前我们在词法分析时生成的符号表,将那些特殊记号记录下来,但是链接时的符号表则更为复杂,会有导出符号表、未解决符号表和地址重定向表三个表,这里我们就简单将它们合并一下,用一个表把类似的原理讲一讲,等后期会出详细的链接过程
首先我们要知道符号的类型有哪些,如下:
- 全局符号(Global symbols):由当前模块定义并能被其他模块引用的符号(指不带static的全局变量)
- 外部符号(External symbols):由其他模块定义,并能被当前引用的全局符号
- 局部符号(Local symbols):仅由当前模块定义和引用的本地符号。例如,定义的static函数和变量
接下来我们可以简单的画出add.c中的符号表,在画的时候我们要注意,在add.c中并没有外部符号的引用,出现的符号add拥有正常的声明和定义,所以会直接分配一个虚拟地址,如图:
接下来我们就来画test.c的符号表,为了进行对比,在test.c的符号表中我们也只画出只有add符号的符号表,其它符号就省略掉,如下图:
可以对比看出来,在add.c中,add符号被看做全局符号,拥有自己的地址,但是在test.c中,add符号被看做外部符号,只有一段可能错误的地址(不一定就是0地址)
链接中关键的就是这一步,由于test.c不认识这个符号所以要报错,为了能够正常链接,我们需要做的就是:在test.c中使用extern关键字对add符号进行声明,然后链接器就会知道,这个符号在其它文件中有定义,先给出一个可能错误的地址,之后再修正
然后最后为了修正test.c中add符号的地址,使它正确,会进行重定位操作,重定位会计算每个定义的符号在虚拟地址空间的绝对地址,将可执行文件中的符号引用处修改为重定位后的地址信息
将符号表修正后,test.c文件的符号表中的add符号的地址就会修正为正确的地址,test.c文件就可以通过这个地址来访问add函数,这就是链接中的重定位
前⾯我们⾮常简洁的讲解了⼀个C的程序是如何编译和链接,到最终⽣成可执⾏程序的过程,其实很多内部的细节⽆法展开讲解。⽐如:⽬标⽂件的格式elf,链接底层实现中的空间与地址分配,符号解析和重定位等,如果你有兴趣,可以看《程序员的⾃我修养》⼀书来详细了解
四、运行环境
在运行环境中有几个要点,我们只需要简单了解一下:
- 程序首先必须载⼊内存中。在有操作系统的环境中:⼀般这个由操作系统完成。在独⽴的环境中,程序的载⼊必须由⼿⼯安排,也可能是通过可执⾏代码置⼊只读内存来完成
- 随后程序开始执行。会直接调⽤main函数
- 开始执⾏程序代码。这个时候程序将使⽤⼀个运⾏时堆栈(stack),存储函数的局部变量和返回地址。程序同时也可以使⽤静态(static)内存,存储于静态内存中的变量在程序的整个执⾏过程⼀直保留他们的值
- 终⽌程序。正常终⽌main函数;也有可能是意外终⽌
今天的编译和链接就讲到这里啦,后面有机会可能还会仔细讲讲链接的过程,这也可能是我们C语言的倒数第二篇博客,下一篇就是C语言的最后一篇了,有没有非常兴奋和有成就感呢?欢迎在评论区留言,有什么问题也欢迎提出
那么今天就到这里,bye~