(翻译)《Expert .NET 2.0 IL Assembler》 第二章 代码加强 2.1 代码压缩
代码压缩
出现在前面章节的代码示例是压缩的。如果你不相信我,进行一个简单的实验:用你喜欢的高级别Microsoft .NET语言写一个类似的应用程序,将它编译为一个可执行体(并保证它运行!),反编译这个可执行体,并将这个结果与第一章提供的示例进行比较。现在让我们试着使这段代码更加简洁。
首先,假定你了解字段映射和作为占位符的值类型,我不需要继续介绍这种技术。如果sscanf接受string作为第一个参数,它也会接受string作为它的第二个参数。其次,我可以在IL指令集中使用特定的“捷径”(我将稍候在本章讨论)。
让我们看一下这个简单的示例,并轻量的修改(源文件Simple.il),正如清单2-1所示。此刻,我感兴趣的部分是改动的部分。
清单2-1 修改过的OddOrEven示例应用程序
//----------- Program header
.assembly extern mscorlib { auto }
.assembly OddOrEven { }
.module OddOrEven.exe
//----------- Class declaration
.namespace Odd.or {
.class public auto ansi Even
extends [mscorlib]System.Object {
//----------- Field declaration
.field public static int32 val
//----------- Method declaration
.method public static void check( ) cil managed {
.entrypoint
.locals init(int32 Retval)
AskForNumber:
ldstr "Enter a number"
call void [mscorlib]System.Console::WriteLine(string)
call string [mscorlib]System.Console::ReadLine()
ldstr "%d" // CHANGE!
ldsflda int32 Odd.or.Even::val
call vararg int32 sscanf(string, string, , int32*) // CHANGE!
stloc.0 // CHANGE!
ldloc.0 // CHANGE!
brfalse.s Error // CHANGE!
ldsfld int32 Odd.or.Even::val
ldc.i4.1 // CHANGE!
and
brfalse.s ItsEven // CHANGE!
ldstr "odd!"
br.s PrintAndReturn // CHANGE!
ItsEven:
ldstr "even!"
br.s PrintAndReturn // CHANGE!
Error:
ldstr "How rude!"
PrintAndReturn:
call void [mscorlib]System.Console::WriteLine(string)
ldloc.0 // CHANGE!
brtrue.s AskForNumber // CHANGE!
ret
} // End of method
} // End of class
} // End of namespace
//----------- Calling unmanaged code
.method public static pinvokeimpl("msvcrt.dll" cdecl)
vararg int32 sscanf(string, string) cil managed { }
这个程序头、类声明、字段声明和方法头开上去是一样的。第一个变动来自方法体,在全局字段Format的地址加载的地方,被加载元数据字符串常量替代:ldstr "%d"。正如先前提及的,你可以在sscanf方法调用的第二个参数中,放弃声明和使用ANSI字符串常量而使用元数据字符串常量(内部用Unicode表示),依赖于P/Invoke提供的封送机制来做这个必需的装换工作。
因为我不再使用ANSI字符串常量、全局字段Format的声明、作为该字段类型的值类型占位符、以及该字段映射到的数据,都被省略了。正如你确实看到的,我不需要使用ILAsm显示声明一个元数据字符串常量——在源代码中唯一提及到的这个常量,对于ILAsm编译器自动省略这个元数据项是足够的了。
改变了sscanf方法调用的第二个参数的本性后,我需要改变sscanf这个P/Invoke thunk的签名,从而能够提供必要的封送。因此,你会看到sscanf签名的改变,包括方法声明和调用方。
另一组变动产生于本地变量的加载/存储指令ldloc Retval和stloc Retval分别被ldloc.0和stloc.0代替。IL定义了特殊的操作代码用来加载/存储清单中第一组编号从0到3的四个本地变量。这么做是有好处的,因为这个指令的常规形式(ldloc Retval)编译在操作代码(ldloc)中,以一个无符号整数(这里是0)作为本地变量的索引,而ldloc.n指令编译在没有参数的单字节操作代码中。
你可能注意到,check方法中所有的分支指令(br,brfalse和brtrue)都被他们各自的简写形式(br.s,brfalse.s和brtrue.s)取代。指令的标准(长)形式编译到紧跟着一个4位的参数操作代码中(在分支指令的情形中,偏移当前的位置),反之,一个短形式编译到紧跟着一个1位的参数的操作代码中。这就限制了分支的范围,从IL流的当前指针向后最大128位,向前127位,但是在此情形中,你可以安全切换到短形式,因为方法相当的小。
带有一个整数或无符号整数参数的短形式,是为IL指令的所有类型定义的。因此,即使你声明的变量多于4个,你仍能够节省一些字节——通过使用ldloc.s和stloc.s指令来取代ldloc和stloc,只要本地变量的索引值不超过255。
高级语言编译器,省略了IL代码,自动估计范围和选择在每个特定情形中使用长形式或短形式的指令。ILAsm编译器,当然不会对排序有什么影响。如果你指定了长形式或短形式的指令,编译器会获取它的表面价值——你是老板,而且你被认为了解的更好。但是一旦你指定了一个短形式分支指令并讲目标标签放在了范围之外,ILAsm编译器就会诊断出一个错误。
有一次,我的一位同事到我这里抱怨IL编译器明显不能编译ILDASM生成的代码。反编译器和编译器被假定为工作决定一致的,因此我被这个发现极大地震惊了。一个简短的研究揭开了严酷的真相。在设计一个用于自动测试程序生成的特殊方法的过程中,我的同事编译C#和VB.NET写的初始程序,然后反编译生成的可执行体,插入特定于测试的ILAsm部分,然后重新编译这段修改过的代码,成为新的执行体,在这个初始的可执行体中的方法,是由C#和VB.NET编译器生成的,是非常小的,因此反编译器会省略这个小的分支语句,这就是在反编译中所显示的。每次我同事的自动化工具在简短分支指令和其目的地之间插入相当多额外的ILAsm代码,这个分支指令,象征性地,就和他的目的地说再见了。
注意到示例中的另一个改变是:指令ldc.i4 1被ldc.i4.1代替。这里的逻辑和用ldloc Retval代替ldloc.0一样——换句话说,一个操作代码的捷径而不需要1个4字节的整型参数。这种捷径ldc.i4.n,n的值从0到8,而-1可以通过ldc.i4.m1这样的代码加载。ldc.i4指令的简写形式为:ldc.i4.s,s的字节整数范围从-128到127。
现在从Apress站点复制Simple.il源文件,使用控制台命令ilasm simple1将其编译成一个Simple1.exe可执行文件,并保证它可以如同Simple.exe一样运行。接着并行反编译这两个可执行文件,使用控制台命令ildasm simple.exe /bytes和ildasm simple1.exe /bytes(/bytes选项使反编译器显示准确的组成IL流程的字节值)。在ILDASM视图树中查找这两个实例的check方法,双击打开反编译窗体,在这个窗体中,你可以比较同一个方法的两种实现,从而看到代码压缩是否在工作。