C/C++编译前,首先要对源代码执行预处理。预处理器(preprocessor)是一个简单的程序,它用程序员(利用预处理器指令)定义好的模式代替源代码中的模式(删除注释、包含其他文件以及执行宏),预处理后生成中间文件.i(文本)。接下来对于.i文件进行语法分析。编译器把源代码分解成小的单元并把它们按树形结构组织起来。表达式中“A + B”中的“A”、“+”和“B”就是语法分析树的叶子节点。语法分析树建立后有时会根据用户定义,使用全局优化器(global optimizer)来生成更短、更快的代码。
全局优化器主要是进行以下优化:
-
局部和全局公共子表达式消除
在此优化中,计算一次公共子表达式的值。在下面的示例中,如果 b 和 c 的值在三个表达式之间没有更改,则编译器可以将 b+c 的计算分配给一个临时变量,并用此变量替代 b + c:
a = b + c;
d = b + c;
e = b + c;
对于局部公共子表达式优化,编译器检查公共子表达式的一小部分代码。对于全局公共子表达式优化,编译器搜索全部函数中的公共子表达式。
-
自动寄存器分配
此优化允许编译器将常用变量和子表达式存储在寄存器中;忽略 register 关键字。
-
循环优化
此优化将不变量子表达式从循环体中移除。最佳循环只包含其值在每次循环执行过程中都要更改的表达式。在下面的示例中,表达式 x+y 在循环体中不更改:
i = -100;
while( i < 0 )
{
i += x + y;
}
优化之后,计算一次 x + y 而不是每次执行循环时都计算:
i = -100;
t = x + y;
while( i < 0 )
{
i += t;
}
当编译器不能假定任何别名时(通过 __restrict、noalias 或 restrict 设置),循环优化更有效。
提示:
在VS的编译中使用带 g 选项的 optimize 杂注,可以逐个函数地启用或禁用全局优化。
优代完成之后,还需要使用代码生成器(code generator)遍历语法分析树,把树的每个节点转化成汇编语言,这个期间保存为中间文件.s(汇编语言 文本)。之后根据用户定义可以使用窥孔优化器(peephole optimizer)从相邻一段代码中查找冗余汇编语句。
窥孔优化,顾名思义,是一种很局部的优化方式,编译器仅仅在一个基本块或者多个基本块中,针对已经生成的代码,结合CPU自己指令的特点,通过一些认为可能带来性能提升的转换规则,或者通过整体的分 析,通过指令转换,提升代码性能。别看这些代码转换很局部,很小,但可能会带来很大的性能提升。这个窥孔,你可以认为是一个滑动窗口,编译器在实施窥孔优化时,就仅仅分析这个窗口内的指令。每次转换之后, 可能还会暴露相邻窗口之间的某些优化机会,所以可以多次调用窥孔优化,尽可能提升性能。窥孔优化可以在四个方面寻找优化机会:冗余指令删除,包括冗余的load和store指令以及死代码(不会执行的代码);控制流优 化;强度削弱;利用特有指令。
- 删除冗余load和store,比如这段汇编代码:
sh $0,6($sp)
完全可以使用指令
sh $0, 4($sp)
sh $0, 2($sp)
sh $0, 0($sp)
ldc1 $f1, 0($sp)xor $f1,$f1,$f1
这样可以省掉5次访存操作,性能的提升非常明显。上面sh是store 16bit到某个地址,ldc1是load 64bit到某个寄存器。xor是异或指令。
但是这种指令序列的转换和合成有个前提,必须保证这些指令按照顺序执行,即这些指令之间,不能有其他标号,即入口。也就是说这些指令必须在一个基本块中。当然,你也可以在编译器较前面阶段的优化中,针对该操作,做变换。这样到了窥孔优化时,就不再会有这样的代码了。 - 删除死代码:
有些代码可能用于不会被执行到,这样在窥孔优化阶段,如果发现这样的代码,就可以直接删除。典型的方式是优化双跳转,即第一条跳转指令的目的地址还是一条跳转指令时,可以删除后一条跳转指令,并修改第一条跳转指令的目标地址。另外,对于不可能进入的分支也可以使用这种方式删除。 - 控制流优化:
中间代码生成阶段,很可能经常产生一些跳转到跳转指令,跳转到分支跳转、分支跳转到跳转之类的指令,都可以在窥孔优化中想办法解决掉,当然你也可以在中间代码中优化它们。一些分支被删除后,可能还存在一些不会被到达的标号(label),也可以顺便删除之,这样就会提升基本块的大小,增加优化机会。 - 强度削弱:
即利用代价较小的指令或操作替代代价较大的指令或操作,从而提升性能。比如x=x+0, x=x*1之类的操作就能直接避免,x=x*2,x=x/2之类的操作可以使用左移或右移实现。x^2之类的指数运算可以削弱为x*x的乘法运算,浮点数除以常数的运算可以转换为浮点数乘以常数的倒数。这些都是优化方式。 - 充分利用特有指令:
CPU都会提供一些特殊指令完成特殊操作,比如DSP芯片中可能有复杂的数字信号处理指令,龙芯中有乘加指令以及一些向量扩展指令。还有一些CPU可能提供自增、自减、取绝对值指令。这些都能在窥孔优化中生成。提升程序的运行性能。
接下来使用汇编器将汇编源文件翻译成对应的机器指令,而且还写入一些东西与机器指令打包成可重新定位目标程序格式的文件,生成中间文件.o(目标文件 二进制)。最后使用连接器(linker)把一组目标模块连接成为一个可执行程序(最终文件),简单的讲,把目标的库文件和所需要的引用的静、动态链接库进行链接,即需要把其他静态库合成到可执行文件中,转换相应的符号引用为地址,然后确保所引用的其他动态链接库的符号存在。此外,链接器还要完成程序中各目标文件的地址空间的组织,这可能设计重定位工作。大多数现代操作系统都提供静态链接和动态链接两种形式。
注:静态类型检查是在建立语法分析树时完成的。
windows程序的启动过程
- 命令解释器(shell)调用了CreateProcess系统函数,创建一个“进程内核对象”。进程内核对象可以看作一个操作系统用来管理进程的内核对象,它也是系统用来存放关于进程统计信息的地方(一个小的数据结构),其实它的真正创建者是一个叫NtCreateProcess的windows2000系统服务函数(也叫执行体服务函数),他创建了进程内核对象供用户扩展。进程内核对象的初始使用计数为1。然后系统为该进程创建4GB(=2^32)的虚拟地址空间(所谓虚拟就不是真的创建4GB的物理内存空间,这些空间不是真在物理内存上)。用于加载App.exe可执行文件和任何必要的dll文件的数据和代码。
- 4G的虚拟内存中,用户进程可以占有2GB的私有地址空间;操作系统占有剩余的2GB空间。在32位x86系统中:
从0x00000000到0x7fffffff的空间中存放着 应用程序代码,全局变量,每个线程堆栈,dll代码。
从0x80000000到0xc0000000的空间中存放着 内核和执行体,HAL(硬件抽象层),引导驱动程序。
从0xc0000000到0xc0800000的空间中存放着 进程页表和超空间。
从0xc0800000到0xffffffff的空间中存放着 系统高速缓存,分页缓冲池,非分页缓冲池。
- CreateProcess打开应用程序文件,它先扫描该文件的文件头,该文件头里含有文件能运行在哪个环境之下,如果是win32环境,系统就直接加载文件的代码和数据并输入(import)该文件执行所需的动态链接库。如果不是win32环境比如时os/2的.exe则先加载相应的环境子系统,载由该环境加载该文件的代码和数据以及该文件执行所需的动态链接库。具体加载动态链接库过程如下
- 加载器(loader)读入可执行程序的导入符号表,根据这些符号表可以查找出该可执行程序的所有依赖的动态链接库。
- 加载器针对该程序的每一个动态链接库调用LoadLibrary
(1)查找对应的动态库文件,加载器为该动态链接库确定一个合适的基地址。如果该基地址和动态链接库希望记载的基地址不同,加载器还要为该库做rebase,然后把整个动态链接库映射到进程的虚拟内存空间中。
(2)加载器读取该动态链接库的导入符号表和导出符号表,比较应用程序要求的导入符号是否匹配该库的导出符号。
(3)针对该库的导入符号表,查找对应的依赖的动态链接库,如有跳转,则跳到第三步
(4)调用该动态链接库的初始化函数
- 进程加载代码和数据完毕后,就开始创建线程来执行进程空间内的代码。进程是静态的,它只是线程的容器。一个进程至少因该有一个线程(main thread),其它线程都是主线程通过调用CreateThread函数创建的。线程也是核心对象,他的实际创建者是一个叫NtCreateThread的windowsNT系统服务函数。一个线程其实只是一个线程核心对象和两个堆栈(一个核
心堆栈,用于线程运行在核心态;一个用户堆栈,用于线程运行在用户态),线程与进程类似,也拥有线程核心对象计数和线程句柄,这里不详述。线程用于描述进程中的运行路径。每当进程被初始化时,系统就要创建一个主线程。该线程与c/c++运行时库的启动代码一道开始运行,启动代码则调用进入点函数(就是我们的main函数,它也是主线程的进入点函数),并且继续运行直到进入点函数返回并且c/c++运行时库的启动代码调用ExitProcess为止。每个线程函数必须有一个返回值,它将作为线程的退出代码。对于主线程来说,这个返回值将传给c/c++运行时库的启动函数。 - c/c++运行时库的启动函数其实是一个程序的真正调用的第一个函数,它是在程序链接时由链接程序选择相应的启动函数并加到程序的开始处。c/c++运行时库有四个版本的启动函数,他们分别对应不同类型的应用程序。比如:
需要ANSI字符和字符串的GUI应用程序的启动函数是WinMainCRTStartup,其对应的进入点函数是WinMain
需要Unicode字符和字符串的GUI应用程序的启动函数是wWinMainCRTStartup,其对应的进入点函数是wWinMain
需要ANSI字符和字符串的CUI应用程序(如控制台console程序)的应用程序的启动函数是mainCRTStartup,对应的入口点函数为main
需要Unicode字符和字符串的CUI应用程序(如控制台console程序)的应用程序的启动函数为wmainCRTStartup,对应的入口点函数为wmain
- c/c++运行时库的启动函数的功能如下(以wWinMainCRTStartup为例):
*检索指向新进程的完整命令行指针;
*检索指向新进程的环境变量的指针;
*对c/c++运行时的全局变量进行初始化;
*对c运行期的内存单元分配函数(比如malloc,calloc)和其他低层I/O例程使用的内存栈进行初始化。
*为C++的全局和静态类调用构造函数。
当这些初始化工作完成后,该启动函数就调用wWinMain函数进入应用程序的执行。
当wWinMain函数执行完毕返回时,wWinMainCRTStartup启动函数就调用c运行期的exit()函数,将返回值(nMainRetVal)传递给它。之后exit()便开始收尾工作:
*调用由_onexit()函数调用和注册的任何函数。
*为C++的全局和静态类调用析构函数;
*调用操作系统的ExitProcess函数,将nMainRetVal传递给它,这使得操作系统能够撤销进程并设置它的exit()代码。 - 以上就是windows 环境下可执行程序的启动过程
Linux程序启动过程
- 输入命令,回车
- exec系统调用接管,为应用程序的运行准备一些环境便利爱那个等,并且为运行的命令找到相应的解释器。
- 通常应用程序解释器就是ld,ld接管控制权后先需要读取这个可执行程序的文件的一部分,包括文件头及共享对象(so文件)
- 针对每一个依赖的库,ld需要首先读入这个so的一部分文件头和相关信息,然后递归查找该共享对象所依赖的其它共享对象,直到最底层。
- ld会把所有依赖的so映射到该程序进程空间的虚拟内存中(注意是 映射不是读入),由于,每一个共享对象在该进程的虚拟内存空间中占据不同的连续区域,他们的“基地址各不相同”,从而其内部的一些用绝对地址表示的符号需要做出相应的修改
- 初始化应用程序的全局变量,对于全局对象子哦的那个调用构造函数
- 从入口函数开始执行