高级语言写就的程序如何跑起来,编译器上的build命令其实干了四件事:
预处理 (文本文件->文件文件)
编译 (文本文件->文本文件)
汇编 (文本文件->二进制文件)
链接
文本文件:数字、字母等都用指定编码如ASCII码/UNICODE码等方式编码并存储的二进制文件。
二进制文件:编码格式自由的二进制文件。
今天不讲链接,只讲前三个功能,不求精细,但求思路连贯。
每个.cpp文件是一个编译单元,.h文件不是,编译器对每个编译单元分别处理。
展开宏定义:#define #undef
处理预编译指令:#if #ifdef #ifndef #elif #else #endif
递归处理头文件:#include
删除注释
添加行号和文件名标识别,用于debug
前端(平台无关)
词法分析:形象点说就是扫描器,将字符识别为有意义的记号:关键字、标识符、字面量(数字、字符串)、操作符等,顺便将标示符放到符号表,数字、字符串放到文字表等,链接阶段要用。
语法分析:对每个表达式构造语法树-操作符在父节点上,左右操作数在左右孩子上。
语义分析:语法对不代表这句话有意义,不过这阶段只能做静态语义分析,给每个语法树节点标上类型,静态类型检查、类型转换等都是在这个阶段完成。
看完数学之美会知道,自然语言处理已经放弃了语义分析这些传统方法,转而采用统计学方法,一大原因就是自然语言是上下文相关的,而人类发明的高级语言是上下文无关的。
源代码优化:将语法树用 三地址码( x = yOPz)等格式表达,容易做优化,优化的代码包括常量相加之类。
后端(平台相关)
汇编语言生成:将中间代码转化为汇编指令。
汇编指令优化:比如用移位代替乘法等。
这项工作比较简单,将汇编指令转化为机器指令,参考汇编指令与机器指令对照表一对一翻译。
最终获得的文件称为可重定位或共享目标文件。
可执行文件格式
可执行文件格式主要有两种:
PE( Portable Executable ) Windows下。
ELF( Executable Linkable Format ) Linux下。
两者都是COFF(Common File Format)格式的变种。
由于目标文件一般与可执行文件采用相同的格式存储,所以广义上将目标文件与可执行文件看做同一种类型的文件。
Linux下,统称为ELF文件,或者统称为目标文件
ELF文件类型说明生成实例
可重定位文件(Relocatable File)可用于链接为可执行文件或共享目标文件编译器和汇编器Linux .o, Windows .obj, 静态库
共享目标文件(Shared Object File)特殊的可重定位文件,可链接为新目标文件;动态链接器将之与可执行文件结合编译器和汇编器Linux .so, Windows .dll
可执行文件(Executable File)可以直接执行链接器Linux 无拓展名, Windows .exe
下表只包含主要段,每个段基本上都有独特的数据结构定义。
ELF文件部分段
ELF Header文件头,说明存储方式、版本、运行平台,程序头入口地址和长度,段表的位置、数量和长度
.text代码段,机器指令流
.data数据段,初始化的全局变量、局部静态变量
.rodata只读数据段,放置只读变量和字符串常量
.BSS存放未初始化的全局变量和局部静态变量,但由于强弱符号特性,未初始化全局变量有时并不放这里
.comment编译器版本信息
.strtab字符串表,变量的名字等各种字符串
.shstrtab段名表,每个段的名字
Section Table段表,头文件之后最重要的部分,存储各段的段名-在段名表的偏移,段的长度、偏移,读写权限等
.symtab符号表,最重要的是全局符号:函数和全局变量信息,符号名(字符串表偏移)、类型、值(可重定位文件中指在代码段或数据段偏移,可执行文件中指虚拟地址)
.rel.text重定位表,记录了.text中需要修改的位置(引用了其他编译单元的全局符号),链接时需要更改
.rel.data重定位表,记录了.data中需要修改的位置(定义或者引用的全局符号位置),链接时需要更改
链接分两种:
静态链接:加载前进行,各个编译单元生成可重定位目标文件(.a或者.obj)后,将它们组合成可执行文件。
动态链接:加载时进行,对于共享目标文件(.so或者.dll),静态链接阶段不处理这些文件,只是在生成的可执行文件中添加.interp段、.dynamic和.dynsym等段,其中记录关于动态链接器地址、依赖的共享目标文件与重定位信息和符号表等信息。
注:加载的意思就是创建进程,开始运行之前。
本文只谈静态链接,静态链接分为两个阶段:1. 虚拟空间分配 2. 符号解析及重定位
由前一篇知,每个可重定位目标文件都包含了很多段,如.text/.data等,那么如何把多个可重定位文件合并为一个可执行文件呢?
如果按序叠加,也即所有可重定位文件原封不动摞在一起,这会导致段太多,且相同名称段分散。实际处理的办法是相似段合并。
那么虚拟空间分配的工作就清楚了:
扫描所有可重定位文件,获取每个段的长度、属性和位置。
合并同名段,并给合并后的段分配一个虚拟地址(虚拟地址占据的位置和字节大小在ELF文件的数据结构中早就留好了),虚拟地址分配有规则(因为这里的虚拟地址对应的就是加载后进程的虚拟空间)。
收集各文件符号表所定义或者引用的所有全局符号到同一个符号表。
符号地址确定,由前一篇目标文件格式知道,每个可重定位文件都有一个符号表,里面存储全局符号的名字、属性和值(在xx段的偏移)。既然已经知道每一段的虚拟地址,那么可以计算得到每个符号的虚拟地址。
既然所有全局符号的虚拟地址都知道了,那么接下来就需要进行符号重定位操作。
重定位:
原因:CPU执行代码段指令,指令中指明去哪个虚拟地址读取信息,可重定位文件代码段如果引用外部符号,那么外部符号的虚拟地址(符号定义的位置)就需要在知道每个符号的虚拟地址后进行更新(未知时填的是0)。
依据:由目标文件的格式知道,其包含重定位表,重定位表描述.text/.data段任何需要更改的指令的位置信息。
过程:
符号解析:每个重定位位置,都是对一个全局符号的引用,必须知道这个符号的虚拟地址,如果在全局符号表找不到这个符号,则报符号未定义错误。
指令修正:按照重定位表给出的记录的修正方式进行修正,有多种方式。
动态链接的原因
由于代码量的膨胀,需要分为不同模块进行开发,最后将互相依赖的模块组合,这是链接的目的。前文讲了静态链接,在程序加载之前生成一个可执行文件。但是静态链接也有明显的弊端:
浪费内存和磁盘空间:任何一个可执行文件都需要拷贝使用到的目标文件。
不利于程序的发布和更新:如果更新了程序,那么必须重新获得可执行文件。
没有插件功能:不能够开放接口,将实现交给使用方自定义。
兼容性较差:程序在不同平台运行时,不能动态选择依赖的链接库。
动态链接:在程序加载时才进行链接(当然也可以在运行时进行手动动态链接),提高了灵活性,但也降低了启动速度。
加载可执行文件和动态链接库到内存;
将执行流程转到动态链接器(一段共享目标文件),其进行符号解析与重定位工作,不像静态文件一样直接转到main函数开始执行;
动态链接器工作完成后,转到main函数开始执行。
动态链接库的核心是如何生成地址无关的代码和数据,有两个问题需要解决:
共享目标文件是共享的,不应该进行重定位(加载地址相关)。
可执行文件的代码是不能进行重定位的。
模块内部的函数调用和变量引用采用相对地址;模块间的函数调用和变量引用采用间接方式访问,也即添加一个.got段(全局偏移表),其存储所引用符号的真正地址。那么共享文件的代码段和不可改数据段就可以共享了,.got段和可变数据才每个进程私有。
可执行文件不能重定位,那么其依赖的动态库符号必须认为是在可执行文件中定义。这就带来了一个新问题——共享目标文件也定义了此符号,怎么办?
解决办法就是认为所有共享目标文件定义的符号都是在可执行文件中定义。如果可执行文件中确实已经定义,将.got段符号真正地址设为可执行文件中的地址;如果可执行文件中未定义,那么将.got段符号地址设为共享目标文件中的地址。