转载https://www.jianshu.com/p/7d11045a40f8
对于任何一个学习过C语言的来说,“HelloWorld”程序都不会陌生。因为它应该是你打开新世界的看到的第一束光。至今我还记得第一次敲出这个程序的时候激动了好久。但是你们知道短短的几行代码,是怎么让程序运行起来的么?
// hello.c
#include
int main(int argc, char *argv[]) {
printf(“Hello World!\n”);
return 0;
}
程序是如何运行起来的?很多人可能会说,不就是五个步骤:预处理(Prepressing),编译(Compilation),汇编(Assembly)和链接(Linking),装载(Loading)么?是这样的。但是你知道每一步背后都做过一些什么吗?如果你能回答上以下的问题,我想这个文章就没有必要看下去了。
在main()函数调用之前,程序做过一些什么?
编译出来的可执行文件里面有什么,在内存中是什么样子的,是怎么来组织的?
静态链接、动态链接,有什么区别?
不同的编译器(Micrsoft VC/VS, GCC)和不同的硬件平台(X86,SPARC,MIPS,ARM),以及不同的操作系统(Windows,Linux,Unix,Solaris),最终编译出来的结果一样么?
ELF文件,PE文件,COFF文件,是什么?
如果你发现对其中的一些问题,不是很了解的话,甚至没有想过这些问题的时候,而你有向了解一下,那么就可以,跟着我的步伐一步俩步,往下看啦。这个文章是为你准备的。需要声明的是,本文主要针对gcc编译器,也就是针对C和C++,不一定适用于其他语言的编译。下图为总览。
GCC编译过程
预处理
预处理的过程,其实,主要是处理那些源代码中以#开始的预编译指令。比如#include,#define等,处理的规则如下:
将所有的#define删除,并且展开所有的宏定义
处理所有的条件预编译指令,比如#if, #ifdef, #elif, #else, #endif等
处理#include预编译指令,将被包含的文件插入到该预编译指令的位置。在这个插入的过程中,是递归进行的,也就是说被包含的文件,可能还包含其他文件。
删除所有注释 //和/**/.
添加行号和文件标识,以便编译时产生调试用的行号及编译错误警告行号。
保留所有的#pragma编译器指令,因为编译器需要使用它们。
对于第一步预编译的过程,可以通过以下方式完成:
gcc -E hello.c -o hello.i
或者
cpp hello.c > hello.i
编译
编译过程可分为6步:词法分析、语法分析、语义分析、源代码优化、代码生成、目标代码优化。对应与下图的每一步。下面我们以一个具体的表达式进行分析:
array[index] = (index + 4)*(2 + 6);
Compilation
词法分析:扫描器(Scanner)将源代的字符序列分割成一系列的记号(Token)。
记号 类型
array 标记符
[ 左方括号
index 标记符
] 右标记符
= 赋值
( 左圆括号
index 标记符
语法分析:语法分析器将记号(Token)产生语法树(Syntax Tree)。
Syntax Tree
注:yacc工具(yacc: Yet Another Compiler Compiler)可实现语法分析,根据用户给定的语法规则对输入的记号序列进行解析,从而构建一个语法树,所以它也被称为“编译器编译器(Compiler Compiler)”。
语义分析:编译器所分析的语义是静态语义,所谓静态语义就是指在编译期可以确定的语义,通常包括声明,和类型的匹配,类型的转换。
Commented Syntax Tree
注:与之对于的为动态语义分析,只有在运行期才能确定的语义。
源代码优化:源代码优化器(Source Code Optimizer),在源码级别进行优化,例如(2 + 6)这个表达式,其值在编译期就可以确定。优化后的语法树。
Paste_Image.png
但是直接作用于语法树比较困难,所以源代码优化器往往将整个语法数转化为中间代码(Intermediate Code)。
注:中间代码是与目标机器和运行环境无关的。中间代码使得编译器被分为前端和后端。编译器前端(1-4步)负责产生机器无关的中间代码;编译器后端(5-6步)将中间代码转化为目标机器代码。
目标代码生成:代码生成器(Code Generator)。
目标代码优化:目标代码优化器(Target Code Optimizer)。
最后的俩个步骤十分依赖与目标机器,因为不同的机器有不同的字长,寄存器,整数数据类型和浮点数据类型等。
汇编
汇编器是将汇编代码转变成机器可以执行的命令,每一个汇编语句几乎都对应一条机器指令。汇编相对于编译过程比较简单,所以根据汇编指令和机器指令的对照表一一翻译即可。汇编过程可以通过以下方式完成。
as hello.s -o hello.o
或者
gcc -c hello.s -o hello.o
链接
静态链接
把一个程序分割为多个模块,然后通过某种方式组合形成一个单一的程序,这就是链接。而模块间如何组合的问题,归根到底,就是模块如何进行通信的俩个问题:(1) 模块间的函数调用,(2) 模块间的变量访问。但无论是那一个问题,其本质是获取一个地址,函数运行的地址、或者变量存放的地址。
如果熟悉汇编的,应该会知道hello.o文件,既目标文件,是以分段的形式组织在一起的。其简单来说,把程序运行的地址划分为了一段一段的片段,有的片段是用来存放代码,叫代码段,这样,可以给这个段加个只读的权限,防止程序被修改;有的片段用来存放数据,叫数据段,数据经常修改,所以可读写;有的片段用来存放标识符的名字,比如某个变量 ,某个函数,叫符号表;等等。由于有这么多段,所以为了方便管理,所以又引入了一个段,叫段表,方便查找每个段的位置。
当文件之间相互需要链接的时候,就把相同的段合并,然后把函数,变量地址修改到正确的地址上 。这就是静态链接,如下图。
静态链接
但是这里有俩个问题:
对于计算机的内存和磁盘的空间浪费比较严重
想想一下,现在一个静态库,至少都是1MB以上。但是假如有1000个或者更多的程序在链接的时候,都静态链接了它,那么当这些程序运行起来的时候,内存中就会存在1000+相同的副本,还是一模一样的。这样,至少1GB空间就浪费了。
程序的更新,部署,和发布会带来很多麻烦
比如一个程序Program所使用的Lib.o是使用的第三方厂商提供的,那么当该厂商更新了Lib.o(比如修复了一个bug,或者优化了性能),那么Program的厂商就必须要拿到最新版的Lib.o,然后与Program.o链接。将新的Program发给用户。这样,一旦程序任何位置有一个小小的改动,都会导致重新下载整个程序。
动态链接
我们的想法很简单,就是当第一个例子在运行时,在内存中只有一个副本;第二个例子在发生时,只需要下载更新后的lib,然后链接,就好了。那么其实,这就是动态链接的基本思想了:把链接这个过程推迟到运行的时候在进行。在运行的时候动态的选择加载各种程序模块,这个优点,就是后来被人们用来制作程序的插件(Plug-in)。
这里,我们不得不介绍一个东西,叫做动态链接器。它会在程序运行的时候,把程序中所有未定义的符号(比如调了动态库的一个函数,或者访问了一个变量)绑定到动态链接库中。简单的来说就是把程序中函数的地址改正到动态库,之后动态链接器会把控制权交给程序,然后程序执行。
这种在装载时修正地址,经常被称为装载时重定位(Load Time Relocation)。而静态链接时修正,则被称为链接时重定位(Link Time Relocation)。
可能有的人,就要问了,多个程序应用一个库不会有问题么?变量冲突?是这样的。动态链接文件,把那些需要修改的部分分离了出来,与数据放在了一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本,这种方案就是目前被称为地址无关代码(PIC,Position-independent Code)的技术。
链接库
通过上面,我们了解到了动态链接,静态链接。一组相应目标文件的集合,我们称它为库。因而也就有了静态链接库,动态链接库。
静态链接库:在Linux平台上,常以.a或者.o为拓展名的文件,我们最常用的C语言静态库,就位于/usr/lib/libc.a;而在Windows平台上,常以.lib为拓展名的文件,比如Visual C++附带的多个版本C/C++运行库,在VC安装的目录下的lib\目录。
动态链接库:在Linux平台上,动态链接文件为称为动态共享对象(DSO,Dynamic Shared Objects),简称共享对象。他们一般常以.so为拓展名的文件;而在Windows平台上,动态链接文件被称为动态链接库(DLL,Dynamical Linking Library),通常就是我们常见的.dll为拓展名的文件。
装载
介绍装载就不得不介绍三种文件格式了:ELF,PE,COFF。现在PC平台上流行的可执行文件格式(Executable),无论是Windows下的PE(Portable Executable)文件,还是Linux下的ELF(Executable Linkable Format)文件,都是COFF(Common file format)文件格式的变种。可执行文件例如,Windows下的*.exe,Linux下的/bin/bash。其实目标文件,内部结构上来说和可执行文件的结构几乎是一样的,所以一般跟可执行文件格式一起用一种格式进行存储。
下面以ELF文件为例子,介绍。
每一个ELF文件,都会有一个ELF文件头,里面会记录很多关于这个程序相关信息,通过它确定段表,进而确定各个段。总的来说,装载做了以下三件事情:
创建虚拟地址空间
读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系
将CPU的指令寄存器设置为运行库的初始函数(初始函数不止一个,第一个启动函数为:_start),初始了main()函数的环境,然后指向可执行文件的入口
作者:Torival
链接:https://www.jianshu.com/p/7d11045a40f8
來源:简书
简书著作权归作者所有,任何形式的转载都请联系作者获得授权并注明出处。