C语言-程序环境 #预处理 #编译 #汇编 #链接 #执行环境
文章目录
前言
一、程序的环境翻译和执行环境
二、翻译环境
(一)、整体把握
(一)、编译
1、预处理(预编译)
2、编译
a、词法分析
b、语法分析
c、语义分析
d、符号汇总
3、汇编
(二)、链接
三、运行环境
总结
前言
路漫漫其修远兮,吾将上下而求索;
PS:本文参考了《程序员的自我修养》,致敬大佬们!
一、程序的环境翻译和执行环境
在ANSI C(标准C)的任何一种实现中,存在两个不同的环境:环境翻译、执行环境
- 翻译环境:在这个环境中源代码被转换为可执行的机器指令
- 执行环境:用于实际执行代码
二、翻译环境
(一)、整体把握
在一个工程中会有很多的 .c 文件;
为什么在一个工程中会有多个 .c 文件?
- 在一个开发组中,每个程序都要自己写自己的代码,倘若一堆程序员均将代码写入一个 .c 文件中,可以想象这是非常难以协同的;所以在一个工程之中,大家均是分模块去写的,故而在一个工程中必然会有多个 .c 文件;
编译器是如何处理多个 .c 文件生成可执行程序的呢?
- 每一个 .c 文件被称为源文件,每个源文件均会单独经过编译器处理生成自己相应的目标文件;然后多个目标文件 加上 链接库 经过链接器的处理最终会生成可执行程序;如下图过程所示:
- 组成一个程序的每个源文件均会单独通过编译过程分别形成自己所对应目标代码(object code).
- 每个目标文件由链接器(linker) 将和链接库 捆绑在一起,形成一个单一而完整的可执行程序
- 链接器同时也会引入标准C函数库中任何被该程序所用到的函数,而且它可以搜索到程序员个人的程序库,将其需要的函数也链接到程序中;
注:上图中所有的源文件均是由同一个编译器处理成目标文件的;(每个源文件均会单独通过编译器处理生成目标文件);
头文件是如何被处理的呢?
- 头文件会先合并到源文件中(此合并的意思为拷贝,即将头文件中的内容拷贝放入源文件之中),然后再进行上图所示 的流程;
什么是链接库?
- 我们在写代码的时候,倘若使用了库函数就得包含对应的头文件,例如 使用了库函数 printf 就得包含其对应的头文件 <stdio.h> ;而所包含的头文件,其涉及到依赖的库也是一个由库文件的提供,只有当你将其编译进你的程序之中,才可以使用此库函数。
- 故,链接库同理;库函数所依赖的东西会在链接库中提供,然后再将链接库与目标文件链接到最终的可执行程序之中,于是乎其整体(使用了库函数的程序)便可以使用此库函数了;
在整个程序生成可执行程序中会遇到两个工具 : 编译器、链接器;
编译器、链接器的工具是什么呢?
- 在VS底下编译器用的是 cl.exe ,链接器用的是 link.exe ;
2、细节深入
在整体把握中能明显感受到,多个源文件会由同一个编译器单独处理生成目标文件,而链接器会将这些生成的目标文件与链接库一起生成可执行程序;
在编译的过程中,还包含着三个过程:预处理(预编译)、编译、汇编;
(一)、编译
1、预处理(预编译)
在预处理阶段,源文件会被处理成 .i 为后缀的文件;
汇编过程的指令如下:
gcc test.c -E -o test.i
gcc -test.c 是让gcc编译器编译源文件test.c
-E 代表着让gcc编译器将预处理阶段处理完便停止
-o (output)代表着指定一个输出
-o test.i 就是将gcc 编译器处理完预处理阶段的数据存放到文件test.i 之中
预处理阶段主要处理的是那些源文件中# 开始的预编译指令;比如:#include #define ,
- 处理#include 预编译指令,将包含的头文件的内容插入到该预编译指令的位置;这个过程是递归进行的,也就是说被包含的头文件也有可能会包含其他文件;
- 将#define 定义的标识符常量给替换掉,并且删除所定义的符号;
- 删除注释(PS: 注释是给程序员看的,这些注释对于程序本身来说没什么用,于是乎在预处理阶段便删除了)
- 处理所有的条件编译指令,例如: #if 、#ifdef、#elif、#else、#endif ;
- 添加行号和文件名标识,方便后续编译器生成调试信息
- 保留所有的 #pragma 的编译器指令,编译器后续会使用;
源文件经过预处理后会生成以 .i 为后缀名的文件,在 .i 文件中不再包含宏定义,因为宏已经被展开。并且包含的头文件都被插入到 .i 文件中。所以当我们无法知道宏定义或者头文件的包含是否正确的时候便可以查看预处理后的 .i 文件来确认;
注:这些操作均是将.c 源文件处理成新的文件,然后在新文件上操作的;
2、编译
编译的过程就是将预处理后的文件进行一系列的:词法分析、语法分析、语义分析、符号汇总,之后便会生成对应的汇编代码文件;
编译过程的命令如下:
gcc test.i -S -o test.s
- gcc test.i -S 让gcc编译器处理文件test.i 到编译阶段结束便不再处理
- -o test.s 将gcc编译器编译阶段产生的数据存放到文件test.s 之中
例子程序:array [index] = (index + 4 ) * (2 + 6 );
a、词法分析
首先源代码被输入到扫描器(Scanner),扫描器的任务就是进行简单的词法分析,把代码中的字符分割成一系列的记号(关键字、标识符、字面量、特殊字符等)。在识别记号的同时,扫描器也完成了其他的工作,比如将标识符放在符号表,将数字、字符串常量存放在文字表等,以便后面的操作使用;
将上述的例子程序进行词法分析,进行扫描后便会得到16个记号,如下图:
记号 | 类型 |
array | 标识符 |
[ | 左方括号 |
index | 标识符 |
] | 右方括号 |
= | 赋值 |
( | 左圆括号 |
index | 标识符 |
+ | 加号 |
4 | 数字 |
) | 右圆括号 |
* | 乘号 |
( | 左圆括号 |
2 | 数字 |
+ | 加号 |
6 | 数字 |
) | 右圆括号 |
b、语法分析
加下来的语法分析器(Grammar Parser)将对由扫描器产生的记号进行语法分析,从而产生语法树。整个分析过程采用了上下文无关语法(Context-free Grammer) 的分析手段。上下文无关语法即由语法分析器生成的语法树就是以表达式(Expression)为节点的树;二我们知道,C语言的一个语句是一个表达式,而复杂的语句是很多表达式的组合。
在上面的例子当中的语句就是一个由赋值表达式、加法表达式、乘法表达式、数组表达式、括号表达式而组成的复杂表达语句;经过语法分析之后便会得到如下图所示的语法树:
在上图中可以得知,整个语句被当作了一个赋值表达式;赋值表达式的左边有个下标表达式(数组表达式),其右边是一个乘法表达式;而在下标表达式(数组表达式)中又由两个符号表达式组成……
符号和数字是最小的表达式,它们不是由其他的表达式来组成的,所以它们又通常被作为整个语法树的叶节点;
在语法分析的同时,很多运算符号的优先级和含义也被确定下来;
另外有些符号具有多重含义,例如* ,在C语言中可以标识乘法表达式,也可以表示对指针取类容的表达式,所以在语法分析阶段必须对这些内容进行区分;倘若出现表达式不合法,比如各种括号不匹配、表达式中缺少操作符等,编译器均会报出语法分析阶段的错误;
c、语义分析
由语义分析器(Semantic Analyzer)完成,语法分析仅仅是完成对此表达式语法层面的分析,并不会去了解这个语句是否真的具有意义。编译器能做的就是语义的静态分析。静态语义分析通常包含声明和类型匹配,类型的转换。
经过语义分析阶段以后,整个语法树的表达式便都被标识了类型,倘若有些类型需要做隐式转换,语义分析程序便会在语法树中插入相应的转换节点;
如下图所示(还是同一例子):
d、符号汇总
符号汇总会将代码中全局、静态的符号全部汇总出来;
注:局部变量是无需汇总的,因为局部变量只有当程序运行起来进入其相对应的作用域时才会创建,故而局部变量无需汇总;
经过词法分析、语法分析、语义分析、符号汇总,在文件test.s 中存放的为源文件中代码所对应的汇编代码;
3、汇编
汇编器将汇编代码转变成机器可执行的指令(二进制指令),每一个汇编语句几乎都对应着一条机器指令;转换的流程就是根据汇编指令与机器指令的对照表一一地进行翻译,无需做指令优化;
汇编的指令如下:
gcc test.s -c -o test.o
汇编过程中的具体细节:
首先是会形成符号表,然后再将汇编指令转换成二进制指令;
注:什么叫作形成符号表?所谓形成符号表就是将在上阶段(编译)中的符号汇总再关联地址形成个表;
符号表是怎么形成的呢?
- 会为对应的符号找补一个地址形成一个表格;(下图中的地址为举例而随便给的)
符号表有什么用?
- 在链接阶段会使用到符号表;
(二)、链接
- 合成段表
- 符号表的合并和符号表的重定位
源文件经过预处理、编译、汇编 会形成 以 .o 为后缀的文件,即目标文件,此文件是有具体格式的,在Linux 环境下有一种 elf 格式。(目标文件是中的内容是二进制形式的,在Linux 下按照elf 这种格式来组织其文件中的内容);
elf 格式会将目标文件分成一段一段的,每一段可以放置不同的数据(每个段均会放置某一种属性的数据);
假设此处有两个目标文件,即 test.o 与 add.o
那么在此链接过程当中,会将test.o 与 add.o(多个目标文件)+ 链接库 进行链接,输出一个可执行程序;(在 Linux 环境下的可执行程序也是 elf 格式);
将elf 格式的文件合并,即在一个采用 elf 格式的文件中,不同的段中会放格子相同类型的数据,再将不同 elf 格式的文件其相同的段合并起来而形成一个文件;这个过程便是合并段表,如下图所示:
简单来说就是,在Linux 下,以 .o 为后缀的文件存在 elf 格式,即会将数据“分类”存在不同的段中(相同类型的数据会放在同一个段中),在链接阶段首先会将test.o 与 add.o 合并成一个文件,由于二者均为 elf 格式的文件,那么它们合并为一个文件的方式将存放着相同类型的段合并成一个段;
什么是符号表的合并与重定义?
(还是以上述所提例子为例)
符号表的合并:
在符号表中Add 有两项,用哪一项呢?
经过仔细地发现 test.o 中的符号表中的 Add 的地址是无意义的,在 test.c 中只是进行了简单的声明,并无具体的实现,声明的作用也仅仅是让计算机知道有这么个函数,但是函数的具体实现及其真实的地址是不知道的,而在 add.o 中Add 的地址是有意义的地址;故而在合并的过程中,会选择用add.o 中的 Add;
如上图所示,在合并符号表的同时会对其地址进行筛选,即选择了有效的地址而形成了最终的符号表,这个筛选的动作便是符号表的重定位;
如何确定该符号表是有效的?
- 在 test.c 中仅存在Add 函数的声明而未有定义,故而在 test.o 文件所对应的符号表中其存在的Add 的地址是个无效地址;而至于此地址怎么是无效的,取决于编译器的实现(因为在汇编形成符号表的时候,编译器便认定了在test.s 中的Add 所找补的地址为无效地址,那么在合并符号表的时候便会认为该地址为无效地址);
符号表的重定位有什么用?
- 符号表的作用是非常之大的;在链接期间,能否使用Add 函数完全取决于在符号表中Add 函数有没有对应其有效的地址,因为在合并之后,只有当Add 、main 均有自己的有效地址,故而我们可执行程序便会使用符号表去查找此函数的地址,便说明我们可以找到该函数(利用其有效的地址);但倘若 add.c 中没有Add 函数,但是在test.c 中仍然存在对于Add 函数的声明,那么经过预处理、编译、汇编所得到的符号表中Add 函数所对应的地址为无效地址,故而在合并符号表时 Add 函数仍任是对应着无效地址,当生成可执行程序的时候,便无法通过此无效地址而找到Add 函数,所以会报错,并且报的是链接性的错误;
通过上面的讲述,似乎声明仅仅只是告诉计算机有这么个函数,并无其他实质性的作用;在合并符号表的时候,也会对符号表进行重定义;
可能你便会问了,倘若不对此函数进行声明,只要此函数的具体实现在这个工程中,可以使用该函数吗?
- 当然可以,不对此函数进行声明,可以强行使用;会报警告,但是不影响执行;因为实质上可执行程序找Add 函数是在符号表中查找的;
注:C++ 中的重载
在C++中,其编译器会根据其参数的个数、参数的类型等来确定重新产生的名字是什么……(有自己的一套规则),所以当你设计的函数的类型、参数个数不同时,重命名产生的名字也会不一样,那么在符号表中出现的名字也会有所差异;
在编译期间会将符号进行汇总,而在汇编期间形成符号表,在链接阶段会合并符号表并进行重定位;这些操作就是为了在链接期间能够跨文件找到函数--> 在符号表中去查找(符号表的存在得以实现跨文件的使用);
三、运行环境
当第一步将程序放入内存之后,接下来第二步程序便开始执行
而程序是如何开始执行的呢?
- 首先是要找到main 函数的位置
main 函数被翻译成了二进制指令在内存中也存在属于main 函数自己的内存空间,故而首先会跳到main 函数存其对应二进制指令的地方开始执行(找到程序的入口);
程序的执行流程:
- 程序必须载入内存中,在有操作系统的环境中,一般载入的这个操作是由操作系统完成的;而若在独立的环境中(即没有操作系统的环境中),程序的必须用手工载入,也可能是通过可执行代码置入只读内存中来完成;
- 程序的执行便开始,接着便会调用 main 函数;
- 开始执行程序代码,这个时候将使用一个运行时堆栈(stack) (每一个函数在调用的时候均会创建函数栈帧,此函数栈帧便是运行时堆栈),存储函数的局部变量和返回地址。程序同时也可以使用静态内存(静态区),存储于静态内存中的变量在程序的整个执行过程中一直会保留着他们的值;
- 终止程序,正常终止main 函数;也有可能会是意外终止;(正常运行着的函数其程序结束了也便停止了;或者说程序发生了意外,程序崩溃了、内存泄露了、断电了……均属于意外终止)
注:只有将程序载入内存中,才能更好地去运行它;独立环境中的载入,例如:单片机中所讲的"烧板子,烫代码",便是将代码烫到板子上去(没有操作系统,需要借助于外部设备将程序放入内存之中);
总结
1、源文件生成可执行程序如若处理两步便是编译和链接。倘若是四步则是预处理、编译、汇编、链接。
2、预处理:主要处理的是那些源文件中# 开始的预编译指令;比如:#include #define ,
3、编译:词法分析、语法分析、语义分析、符号汇总
4、汇编:将汇编代码转换成机器指令,即二进制指令;
5、链接:
- 合成段表
- 符号表的合并和符号表的重定位