前言
本篇同样是一篇修炼内功的文章
很重要!它将会让你对程序的构建运行有一个更深的认识
一、翻译环境和执行环境
在ANSI C的任何一种实现中,存在两个不同的环境:
- 翻译环境,在这个环境中源代码被转换为可执行的机器指令(二进制指令)
- 执行环境,它用于实际执行代码
在本篇中,我们会来详细介绍一下翻译环境
二、翻译环境
翻译环境是由编译和链接两个大过程组成的,将源代码转换为可执行的机器指令,编译又可以分解成:预处理(预编译)、编译、汇编三个过程
再简要一点,就像如下图
其中,几个.c文件经过编译器的处理,生成了几个.obj文件就叫做编译器,它们再与链接库一起经过链接器处理生成可执行程序.exe,这就叫做链接
具体来说,多个 .c 文件如何生成可执行程序呢?
在我细说之前,你先得有以下认识:
- 多个.c文件单独经历编译处理生产对应的目标文件(windows环境下后缀为.obj,Linux环境下后缀为.o)
- 多个目标文件和链接库一起经过链接器处理生成可执行程序
- 链接库是指运行时库(支持程序运行的基本库函数集合)或者第三方库
然后就跟我一起从头开始,构建一个完整的程序的过程吧~
预处理(预编译)
在预处理阶段,源文件和头文件会被处理成为.i为后缀的文件
此阶段主要处理那些源文件中#开始的预编译指令,例如#include,#define
具体规则如下:
- 将所有的 #define 删除,并展开所有的宏定义
- 处理所有的条件编译指令,如: #if、#ifdef、#elif、#else、#endif
- 处理#include预编译指令,将包含的头文件的内容插⼊到该预编译指令的位置
- 这个过程是递归进行的,也就是说被包含的头文件也可能包含其他文件
- 删除所有的注释
- 添加行号和文件名标识,方便后续编译器生成调试信息等,或保留所有的#pragma的编译器指令,编译器后续会使用
如果想在gcc环境下观察,可以输入以下命令行:
gcc -E test.c -o test.i
请注意!经过预处理后的.i文件中不再包含宏定义,因为宏已经被展开。并且包含的头文件都被插⼊到.i⽂件中。所以当我们无法知道宏定义或者头⽂件是否包含正确的时候,可以查看预处理后的.i⽂件来确认
如该图,我们发现#include <stdio.h>被展开,竟然有好几百行!且添加了文件名标识
至于上述其他规则特性,就交由大家自行验证了
编译
编译过程:将预处理后的文件进行一系列词法分析、语法分析、语义分析以及优化,生成相应的汇编代码文件
指令为:
gcc -S test.i -o test.s
假设这个时候来了个语句:
nums[index] = (index + 4) * (2 + 6);
词法分析
将源代码程序被输入扫描器,扫描器的任务就是简单的进行词法分析,把代码的字符分割成一系列的记号(关键字、标识符、字面量、特殊字符等。)
语法分析
将对扫描产生的记号进行语法分析,从而产生语法树(以表达式为节点的树)
语义分析
由语义分析器完成语义分析,即对表达式的语法层面分析。编译器所能做的分析是语义的静态分析。静态分析通常包括声明和类型的匹配,类型的转换等。这个阶段会报告错误的语法信息
到这里,程序的后缀已经变成.s了
汇编
由汇编器将汇编代码转为机器指令(二进制指令),每一句汇编语句几乎都对应一条机器指令。按照汇编指令和机器指令的对照表进行一一的进行翻译,不做指令优化
指令为:
gcc -c test.s -o test.o
然后你会发现看不了,看不了就对了,就算能看也是乱码
链接
将一堆文件链接在一起生成可执行程序,为解决一个项目中多文件、多模块之间互相调用的问题,主要过程:地址和空间分配,符号决议和重定位等步骤,事实上,这很复杂,我也只是粗略点拨一下
现在我们来个具体例子,现在在一个C的项目里面,有两个.c文件(main.c、add.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);
return 0;
}
//add.c
int g_val = 2024;
int Add(int x, int y)
{
return x + y;
}
现在我们开始运行程序,首先通过前文我们知道,两个.c文件经过编译后生成两个.o目标文件,也就是说
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 也是类似的方法来修正地址
这个地址修正的过程也被叫做:重定位
事实上,我第一次知道链接是这样子运行的时候,我还是蛮激动的,这太巧妙了,不得不感叹计算机殿堂里先辈们的智慧
我对这个过程有个还算贴切的比方,假如小举和小帆是好兄弟,小举年纪到了要买房子,首付三十万,此时手里只能拿出二十五万,剩下五万,要找小帆借,小帆答应了,小举就去签字了
这时候,买房子就相当于是要调用Add函数和g_val变量,可是在自己的.c文件没有,对应差五万,找小帆借,小帆答应了,对应着声明,可是声明还需要有定义,此时还只是口头承诺,若另一个文件真的有声明,就能修正为正确的地址,对应小举真的拿到钱,另一种情况则是由于某些原因小帆无法借钱,就像是另一个文件里没有定义,这就导致之前错误的地址无法修正,承诺未能兑现,程序出错,小帆也就交不了首付了,Bad Ending~
三、运行环境
可能你会觉得编译的过程好难啊,或者我讲得好浅啊!
那你感觉对了,我在这里只是讲了点皮毛,大学计科对于编译有一门专门的课程叫做《编译原理》
这可能是计算机类专业最无聊、相对来说最难的一门课了~
那我们接着来讲运行环境吧,在这里我就讲些有关注意点吧:
- 程序必须载入内存中。在有操作系统的环境中:⼀般这个由操作系统完成。在独立的环境中,程序的载入必须由手工安排(嵌入式的烧录),也可能是通过可执行代码置入只读内存来完成
- 程序的执行便开始接着便调用main函数
- 开始执行程序代码。这个时候程序将使用⼀个运行时堆栈(stack),存储函数的局部变量和返回地址。程序同时也可以使用静态(static)内存,存储于静态内存中的变量在程序的整个执行过程⼀直保留他们的值
- 终止程序。正常终止main函数;也有可能是意外终止
总结
来个总览,本篇应该能让你有些启发,下篇我们会对预处理那一部分再详细介绍一下