GCC优化的一个直观理解
0x00 前言
众所周知,gcc有优化标记-O,可以选择优化等级,针对每个等级,会打开不同的优化开关。也可以手动去选择打开这些开关。详细的描述可以去查看gcc手册(https://gcc.gnu.org/onlinedocs/gcc/Optimize-Options.html),此处不再赘述。
本文想给出一个很直观的例子,并通过汇编来分析gcc是如何做优化的,对编译优化有一个简单的理解。
0x01 实验程序
先看代码,递归1000000次后打印2000000:
静态分析来讲,这个程序会因为因为递归的次数过多,栈不停的增长导致内存写坏,然后segfault。
试验一下,不出所料:
0x02 实验结果
试试看,对他进行不同程度的优化,结果会有什么变化:
可以看到,O2以上是可以打印出我们期望的结果的,继续分析一下O1和O2之间发生了什么。
0x03 O1分析
先针对O1的可执行文件进行分析:
进gdb,在main函数断住,看汇编:
可以看到,是有call指令的,也就是会进行函数调用,不出意外栈也是会一直增长的,进add函数看看:
可以看到,开辟了栈空间,但是由于在释放前要递归调用call,所以栈空间在最后一层调用返回前永远不会被释放,导致溢出。
0x04 O2分析
同样先看main的汇编:
会发现程序只有短短的几行,而我们想要的结果2000000(0x1e8480)在第一行就被赋值给了esi,并没有任何函数调用或者是计算的过程。
去查看gcc手册,看看O2到O1打开的优化标记:
每一个都去详细分析的话实在是令人头秃,所以针对这个问题,想找到导致解决溢出问题本质的优化操作,以此为线索进行寻找,我能想到的是三种:
- 临时变量都用寄存器,所以无需在栈上进行任何操作,也就是不用开辟栈空间,自然也不会溢出;
- 尾递归调用方法,函数调用时callee沿用caller的栈,栈顶始终不变;
- inline,直接把函数展开写进main里,把递归变成循环;
根据以上思路,一眼就找到了一个可疑的标记 -foptimize-sibling-calls:
0x05 O1 -foptimize-sibling-calls分析
为了验证猜想,使用O1加上-foptimize-sibling-calls这个tag进行分析:
main函数:
可以看到意料之中的结果,没有了call调用,而是变成了循环,从而解决了栈增长溢出的问题;
0x06 补充分析:O1 对 O0 的优化
先看看O0:
O0 main:
O0 add:
首先直观的可以看到O0的代码体量要比O1大不少,仔细看了一下O1主要在这么两个地方做了优化来提高了效率并减小了代码体量:
- 函数调用的保存更新ebp,返回前的leave(恢复ebp)在O1里是没有的,直接用esp来记录栈;
- 参数复制到当前函数栈的内存(add+8,add+11)指令,在O1中是被优化的,会直接操作入参寄存器esi、edi;
0xff 总结
通过以上的实验和分析,可以简单的总结出:
- O0到O1会做一些栈上的优化,不需要用到栈的地方会直接用寄存器解决,同时也会减少使用寄存器的数量;
- O1到O2会做一些计算上的优化,在语义上做一些文章,比如提前计算好常量,把一些代码段、函数执行的结果直接赋值给寄存器,避免不必要的计算指令;
- O1到O2会做函数调用上的一些优化,对于一些可以inline的函数会直接将其在代码中展开从而免去call指令,也不会有栈上的操作;
O2的优化程度已经相当高了,基本上代码已经“面目全非”,很难通过逆向去猜测原先代码的逻辑了,比如本例中如果只看O2的汇编,肯定不会想到C代码写的是个递归;
O3本文中没有涉及,因为在本例中O2和O3的代码是基本相同的,因此无法从本例中分析O3到O2的优化,如果有需要的话,后续看看能不能根据文档构造一些相关用例了;