原文:http://blogs.msdn.com/xiangfan/archive/2008/09/27/minimize-the-size-of-your-program-low-level.aspx
宏观优化: http://blog.csdn.net/nineforever/archive/2008/10/17/3092791.aspx
注:本文的主要目的在于最小化可执行文件的大小。里面提到的技巧并不适用于实际的应用。生成的PE文件是否有效依赖于特定的架构、操作系统和工具
你知道x86平台上最小的PE文件有多大吗?
答案在这里:http://www.phreedom.org/solar/code/tinype/
PS:你可以使用IDA Pro,WinDBG或者OllyDbg来打开并调试这些TinyPE :P
编写TinyPE的技巧很简单:去除“冗余”的数据,并利用数据结构的相互重叠进行合并。这样PE文件格式本身占用的字节数就能被尽可能的减少(去除“冗余”的数据会导致无效的PE文件头,你的程序可能会无法正常运行)。这里,我们主要讨论如何优化实际代码的大小。
为了研究编译器生成的二进制代码,你可以使用编译器提供的/FAsc选项。
首先我们可以利用TinyPE中用到的技巧优化PE文件头。因为有很多OS的加载器没有使用的字段(注:对于实际的应用,不应当假设PE文件头中的字段没有被使用),为了优化程序的大小,我们可以将程序中用到的数据放到这些字段中。除了节省空间之外,这样做的另外一个好处是,这些数据的地址差别都在0x80以内,便于我们之后的优化。
好,让我们开始吧!
1、算法
a、局部变量初始化
通常,初始化需要很长的指令。
unsigned long keyT[4]={0xCBDCEDFE,0x8798A9BA,0x43546576,0x00102132};
赋值的代码会被转换为类似的汇编:
mov DWORD PTR [ebp+offset8], imm32
这需要7个字节。那么初始化整个数组就需要7*4 = 28字节。
为此,我们可以将初始化改为循环。
for (size_t i=0;i<16;++i) ((char *)keyT)[i]=0xFE-i*0x11;
((char *)keyT)[15]++;
上述代码可以这样实现:
lea edx, [ebp+keyT-1]
xor ecx, ecx
mov cl, 16
mov al, 0FEh
loc_4:
inc edx
mov [edx], al
sub al, 11h
loop loc_4
inc byte [edx]
28 -> 18字节!
b、memcpy
默认编译器会调用msvcrt.dll中的API。但是这样需要很多字节。另一种方法是直接使用汇编代码"rep movs"
如果我们仔细分析一下程序所使用的算法,我们可以发现它实际上将keyT数组向左循环移动了t个字节。
memcpy(Buf1,(char *)keyT+t,0x10-t);
memcpy((char *)Buf1+0x10-t,keyT,t);
memcpy(keyT,Buf1,16);
这意味着大部分传给memcpy函数的参数都可以重用,这样就免去了许多存取的指令。
;ebx == t, edi == end of keyT
xor ecx, ecx
mov cl, 10h
sub cl, bl
mov esi, edi
sub esi, ecx
lea edi, [ebp+Buf1]
push edi
rep movsb
mov cl, bl
lea esi, [ebp+keyT]
rep movsb
pop esi
mov cl, 10h
rep movsb
多亏了参数的重用,后面两次memcpy只使用了12 个字节,而整段代码也只有35个字节!相比较而言,优化之前的代码需要54字节,这还不包括为了调用memcpy API需要在PE文件中添加的超过10个字节。
2、8-bit vs 32-bit
在32位环境下,数据和地址默认都是32位的。而32位的立即数和32位的长跳转占据了我们代码大小的很大一部分。
我们可以尽量将这些数据变小,从而使得它们能够使用8位进行表达。
例如,当我们调用下面的函数时:
printf("%08X",keyT[k]);
编译器会生成如下的指令:
push hexstr+ImageBase ; "%08X"
call [printf+ImageBase]
因为前面我们已经重新安排过数据,所以hexstr和printf的地址之差不超过80字节,这样我们可以改用:
mov eax, hexstr+ImageBase
push eax
add al, printf-hexstr
call [eax]
printf使用cdecl调用约定,由调用者负责压栈出栈。当我们将参数出栈的时候,我们可以将得到的"eax"重用给之后的"printf("/n");"。
add al, newline-hexstr
push eax
add al, printf-newline
call [eax]
22 -> 17字节!
另外,我们可以用两次短跳转代替长跳转(5 -> 4字节)
3、杂项
- 避免使用16位的指令
- 使用"loop"指令进行循环
- 使用enter/leave指令初始化堆栈
- 使用"lods/stos"进行内存读写
- 使用"pusha/popa"替代多次push/pop
- 有些指令用于"eax"寄存器时长度会略短一些
- 有时8位的指令比32位的版本要短
- 我们的程序并不需要返回特定的值,OS也不需要我们保存寄存器,所以最后的返回语句"xor eax, eax"和用于恢复寄存器的"push/pop"指令都可以省略(对于实际的应用,这可能会导致问题)
- 寄存器调度非常重要。如果调度合理,可以避免大量的存取操作
- 充分利用al,ah和类似的寄存器,因为x86平台上可用的寄存器实在太少
如果要获得更完整的优化代码大小的技巧,可以阅读Agner Fog撰写的手册。
PS:不要忘记,PE文件头中,"Debug Directory"的size字段必须是0,否则系统载入你的PE文件时会crash。因为你的代码会和这个字段公用数据,所以你必须在你的代码中插入类似的指令:
jmp short skip_debug
times 4 db 0 ;确保debug directory的size等于0
skip_debug:
这里是完整的汇编源代码:source (你可以用"nasmw -f bin"编译)和优化前后的结果比较:
PE文件头 | 代码 | 数据 | 总大小 | |
优化前 | 480 | 272 | 144 | 896 |
优化后 | 136 | 180 | 7 | 323 |