一个程序从编译到运行的全过程
- 一个程序从编译到运行的全过程
- 编译
- 预处理
- 编译
- 汇编
- 链接
- 载入
- 虚拟内存
- 用户空间
- 总结
一个程序从编译到运行的全过程
每次用编译器写完一个程序后,我们会进行调试和执行,将代码的结果输出在我们的电脑屏幕上,但是我们并不清楚,为什么我们写的这些代码,可以转为我们想要的结果呢?
其实,在ANSI C(标准C)的任何一种实现中,都存在两个不同的环境,即翻译环境和执行环境。
翻译环境下,程序会经过两个阶段,一个是编译,一个是链接。翻译环境内,代码被转换为可执行的机器指令。执行环境内,它用于实际执行的代码。
那么,我们的程序就是进行了这两个过程,才最终将我们想要的结果输出在屏幕上。
一个程序,从编写完代码,到被计算机运行,总共需要经历以下四步:
- 编译:编译器会将程序源代码编译成汇编代码。
- 汇编:汇编器会将汇编代码文件翻译成为二进制的机器码。
- 链接:链接器会将一个个目标文件和库文件链接在一起,成为一个完整的可执行程序。
- 载入:加载器会将可执行文件的代码和数据从硬盘加载到内存中,然后跳转到程序的第一条指令处开始运行。
链接器和加载器是由操作系统实现的程序。而编译器和汇编器则是由不同的编程语言自己实现的了。
这里需要展开来说一说,我们常用的高级语言,按照转化成机器码的方式不同可以分为编译型语言和解释型语言:
- 编译型语言要求由编译器提前将源代码一次性转换成二进制指令,即生成一个可执行程序,后续的执行无需重新编译。比如我们常见的 C、Golang 等,优点是执行效率高;缺点是可执行程序不能跨平台(不同的操作系统对不同的可执行文件的内部结构要求不同;另外,由于不同操作系统支持的函数等也可能不同,所以部分源代码也不能跨平台)。
- 解释型语言不需要提前编译,程序只在运行时才由解释器翻译成机器码,每执行依次就要翻译一次。比如我们常见的 Python、PHP 等,优点是较方便(对编写用户而言,省去了编译的步骤),实时性高(每次修改代码后都可直接运行),能跨平台;缺点是效率低。
- 半编译半解释型语言:还有一类比较特殊,混合了两种方式。源代码需要先编译成一种中间文件(字节码文件),然后再将中间文件拿到虚拟机中解释执行。比如我们常见的 Java、C# 等。
所以,要设计一门语言,还必须为其编写相应的编译器和解释器,将源代码转化为计算机可执行的机器码。由于不同的语言有不同的转化方式,接下来将以最常见的 C 语言为例,简单分析一下 编译→汇编→链接→载入 的过程。
总结:不同的语言会使用不同的方式将源代码转化为机器码,但是之后的链接和载入过程都是由操作系统完成的,都是相同的。
编译
编译是读取源程序,进行词法和语法分析,将高级语言代码转换为汇编代码。整个编译过程可以分为两个阶段。
预处理
- 对其中的伪指令(以 # 开头的指令)进行处理。
- #define 定义的符号替换。我们通常使用define定义标识符和宏。假设,我们这里顶一个一个MAX为100,#define MAX 100 ,那么在预编译阶段,我们程序中的MAX会被直接替换成100。;
- 处理条件编译指令。如 #if、#elif、#else、endif 等;
- 处理头文件包含指令。如 #include,将被包含的文件插入到该预编译指令的位置;
- 删除所有的注释。
- 添加行号和文件名标识。
编译
对预处理完的文件进行一系列词法分析、语法分析、语义分析及优化后,产生相应的汇编代码文件。
过程比较复杂,主要包括四个步骤:
- 词法分析。这一过程是将字符序列转为单词序列。这里的单词是一个字符串,是构成源代码的最小单位。从输入字符流中生成单词的过程叫作单词化,同时会对单词进行分类。比如:sum = 2+3; 将会拆成sum、=、2、+、3 这些单词。
- 语法分析。这一过程是分析单词符号串是否形成符合语法规则的语法单位。如表达式、赋值、循环等,最终是否构成一个符合要求的程序,再按照对应语言的语法规则检查每条语句是否正确。
- 语义分析。语义分析是对结构上正确的源程序进行上下文有关性质的审查,进行类型审查。比如语义分析的一个工作是进行类型审查,审查每个运算符是否具有语言规范允许的运算对象,当不符合语言规范时,编译程序 应报告错误。
- 符号汇总。这个环节会将源文件的全局函数的函数名进行汇总。
汇编
将编译完的汇编代码文件翻译成机器指令,保存在后缀为 .o 的目标文件(Object File)中。同时会给每个源文件汇总出来的符号分配一个地址,然后生成一个符号表。
这个文件是一个 ELF 格式的文件(Executable and Linkable Format,可执行可链接文件格式),包括可以被执行的文件和可以被链接的文件(如目标文件 .o,可执行文件 .exe,共享目标文件 .so),有其固定的格式。
链接
由汇编程序生成的目标文件并不能被立即执行,还需要通过链接器(Linker),将有关的目标文件彼此相连接,使得所有的目标文件成为一个能够被操作系统载入执行的统一整体。
例如在某个源文件的函数中调用了另一个源文件中的函数;或者调用了库文件中的函数等等情况,都需要经过链接才能使用。
在链接阶段,编译器主要完成两个任务:
- 合并段表。在汇编结束生成的obj文件,内部其实会被划分为几个段,在链接过程中会将这些段进行合并。
- 符合表的合并和重定位。在链接过程中,我们会对不同的符合分配一个相应的地址。而有的时候,一些符号在它所处的文件中并不存在,存在于另一个文件中,这时候我们会提前分配一个无意义的地址。在合并之后,会将合法的地址重新定位覆盖原来无意义的地址。
链接处理可以分为两种:
- 静态链接:直接在编译阶段就把静态库加入到可执行文件当中去。优点:不用担心目标用户缺少库文件。缺点:最终的可执行文件会较大;且多个应用程序之间无法共享库文件,会造成内存浪费。
- 动态链接:在链接阶段只加入一些描述信息,等到程序执行时再从系统中把相应的动态库加载到内存中去。优点:可执行文件小;多个应用程序之间可以共享库文件。缺点:需要保证目标用户有相应的库文件。
载入
加载器(Loader)会将可执行文件的代码和数据加载到内存(虚拟内存)中,然后跳转到程序的第一条指令开始执行程序。
虚拟内存
首先,为了避免进程所使用的内存地址相互影响,操作系统会为每个进程分配一套独立的虚拟内存地址,然后再提供一种机制,将虚拟内存地址和物理内存地址进行映射。
我们程序所使用的内存地址叫做虚拟内存地址(Virtual Memory Address),实际存在硬件里面的空间地址叫物理内存地址(Physical Memory Address)。
用户空间
然后,操作系统将整个内存空间分为用户空间和内核空间,其中内核空间只有内核程序能够访问,且所有进程共用一个内核空间;而用户空间是专门给应用程序使用的,每当创建了一个新的进程,都要分配一个用户空间。
接下来以 32 位内存空间为例进行说明,32 位内存空间大小为 4GB,其中 1GB 为内核空间,3GB 为用户空间。用户空间中按照数据类型不同,划分为了不同的内存段,各类数据会被存放到各自的内存段中。
用户空间内存,从低到高分别是 6 种不同的内存段:
- 程序文件段(.text),包括二进制可执行代码;
- 已初始化数据段(.data),包括静态常量;
- 未初始化数据段(.bss),包括未初始化的静态变量;
- 堆段,包括动态分配的内存,从低地址开始向上增长。当进程调用malloc等函数分配内存时,新分配的内存就被动态添加到堆上(堆被扩张);当利用free等函数释放内存时,被释放的内存从堆中被剔除(堆被缩减);
- 文件映射段,包括动态库、共享内存等,从低地址开始向上增长;
- 栈段,包括局部变量和函数调用的上下文等。栈的大小是固定的,一般是 8 MB。当然系统也提供了参数,以便我们自定义大小;栈段可以通过系统调用自动地扩充空间,但是不能回收空间,所以栈段设置得太大会导致内存泄露。
至此,可执行文件载入内存的过程可以概括为以下几步:
- 给进程分配虚拟内存空间;
- 创建虚拟地址到物理地址的映射,创建页表;
- 加载代码段和数据段等数据,即将硬盘中的文件拷贝到物理内存页中,并在页表中写入映射关系;
- 把可执行文件的入口地址写入到 CPU 的 指令寄存器(PC)中,即可执行程序。
总结
本文简单的描述了一下一个程序,从编写完代码,到被计算机运行的过程,其实当中的每一步都十分复杂深奥,都值得深入学习,尤其是最后一步载入内存的过程,展开来说可以涉及到整个操作系统的内存管理,像分段、分页、多级页表、TLB、内存分配、内存泄露、内存回收、页面置换算法等等。