(翻译)《Expert .NET 2.0 IL Assembler》 第一章 简单示例 1.2 简单示例(二)

返回目录

 

字段声明

     以下是OddOrEven应用程序的字段声明:

.field public static int32 val

     这一句定义了一个名为Field Definition(又名FieldDef)的元数据项。因为这个声明出现在Odd.Or.Even类的范围内,所以这个声明的字段属于这个类。

     关键字publicstatic定义了FieldDef的标记。

     关键字public识别了这个字段的可访问性,意味着此字段可以被这个类的任何成员访问

     可选择的,可访问性标记如下所示:

          assembly标记,指定了字段可以在所在程序集的任何位置被访问,但是不能在程序集外被访问。

          family标记,指定了字段可以在所在类Odd.Or.Even的任何位置被访问,

          famorassem标记,指定了字段可以在所在程序集的任何位置被访问;也可以在所在类Odd.Or.Even的任何位置被访问,即使这个类是在所在程序集的外部声明的。

          private标记,指定了字段只可以在所在类Odd.Or.Even中被访问。

          privatescope标记,指定了字段可以在当前模块的任何位置被访问。这个标记是默认的。privatescope标记是一个特例,我强烈建议你不要使用它。私有范围(Private scope)项免除了必须有一个唯一的父级/名称/签名三元组的需要,这意味着你可以在同一个类中定义两个或更多具有相同名称和类型的私有范围项。一些编译器为了它们的内部意图而发布了私有范围项。这就是编译器的问题来区别两个不同的私有范围项;如果你决定使用私有范围项,你至少应该给它们唯一的名称。因为默认的可访问性是privatescope,这可能是一个问题,因此记得指出可访问性标记就很重要了。

     关键字static意味着这是一个静态字段——被类Odd.Or.Even的所有实例所共享。如果字段没有标记为static,那它就是一个实例字段,独立于这个类的特定实例。

     关键字int32定义了字段的类型,一个32位的有符号整数。(第8章描述类型和符号。)当然,这里val是这个字段的名称。

     你可以在第9章获得更多字段声明的详细解释。

 

方法声明

     以下是OddOrEven应用程序的方法声明:

.method public static void check( ) cil managed {
     
.entrypoint
     
.locals init(int32 Retval)
     
}

 

.method public static void check( ) cil managed { ... }

     这一句定义了一个名为Method Definition(又名MethodDef)的元数据项。因为这个声明出现在Odd.Or.Even类的范围内,所以这个方法是这个类的一个成员。

     关键字publicstatic定义了MethodDef中的标记,意味着这和在前面章节讨论的FieldDef中的同名标记是一样的。MethodDefFieldDef并不是所有的标记都相同——参见第9章和第10章得到详细信息——但是可访问性标记和关键字static,对于字段和方法而言,具有相同的意义。

     关键字void定义了方法的返回类型。如果方法有一个不同于默认值的调用约定(calling convention),你可以放置相应的关键字在标记后面、返回类型前面。调用约定、返回类型和方法参数的类型,定义了MethodDef的签名。注意到没有参数则表示为(),而不是(void)。(void)的写法意味着这个方法有一个类型为void的参数,这是一种非法的签名。

     包包译注:调用约定,指的是方法调用时如何传参、返回值,不分语言、平台,但分方式,比如C风格的、WinAPI的。IL中可以自定义方法的调用约定,但是C#这种高级语言对此不支持。

    关键字cilmanaged定义了所谓的MethodDef的实现标记,并指示了方法体是用IL表示的。用本地代码而不是IL表示的方法,会携带实现的标记native unmanaged

     现在,让我们进入到方法体。在ILAsm中,方法体(或者说方法范围),通常包括三类项目:指令(instruction,编译成IL代码)、标记在指令上的标签、指示符(directive,编译成元数据、头的设置、托管的异常处理子句等等,简而言之,除了IL代码的任何元素。)在方法体外部,只存在指示符。到目前为止,讨论到的每个声明都是指示符。

     包包译注:这里,instruction统一翻译为指令,directive统一翻译为指示符。

     .entrypoint唯一标志了当前方法作为应用程序(程序集)的进入点。每一个托管的EXE文件必须有一个单一的进入点。ILAsm编译器拒绝编译一个没有指定进入点的模块,除非你使用 /DLL 这个命令行选项。

     包包译注:进入点(entrypoint),就是C#中的main函数。

     .locals init(int32 Retval) 定义了当前方法的单一本地变量。这种变量的类型是int32,它的名称是Retval。关键字init意味着本地变量将会在运行期方法执行之前被初始化。如果带有这个关键字的本地变量没有指定在程序集的任何一个方法中,这个程序集就会验证失败(由CLR执行安全检查)并只能运行在完全信任(full-trust)模式——当验证不被支持时。出于这个原因,千万不要忘记在本地变量声明时使用关键字init。如果你需要更多的本地变量,你可以在括号中列出它们,并使用逗号分割。例如:

.locals init(int32 Retval, string TempStr) 

 

AskForNumber:
     
ldstr "Enter a number"
     
call void [mscorlib]System.Console::WriteLine(string)

     AskForNumber: 这是一个标签。它不需要占用一个单独的行;IL反编译器将每一个在同一行带有标签的指令标记为一个指令。标签并没有编译成元数据或IL;而只是在编译期间用作识别IL代码中特定的偏移量。

     一个标签标记了跟在它后面的第一条指令(instruction)。标签并不标记指示符(indirective)。换句话说,如果把AskForNumber这个标签向上移动两行,从而使.entrypoint.locals将标签与第一条指令分隔开,那么这个标签仍然是标记了第一条指令。

     在我继续下面的指令之前,有一点重要的声明:IL是一种严格基于堆栈的语言。每一个指令都会从栈顶获取一些东西(或者没有)和将一些东西放进栈中(或者没有)。除了实参之外,一些指令还有形参而另一些没有,但是基本的规则并没有改变:指令从栈中获取所有所需要的实参(如果有的话)并将结果(如果有的话)放入栈中。没有IL指令能直接记录本地变量或方法形参的地址,除非是loadstore群组的指令——分别将变量或形参的值或地址放到栈中,或者从栈中取出并将其放入一个变量或形参中。

     包包译注:argument翻译为实参,或直接称为参数Parameter翻译为:形参。二者在编程中是完全区别的。     

     IL栈的元素并不只是字节或者字(word),还有槽位(slot)。当我谈及IL栈的深度时,就被放入栈中的元素而言,我并没有留意每个元素的大小。LI栈的每个槽位携带了关于当前“占位者”的信息。如果你将一个int32元素放入栈中,然后试图执行一条指令,例如,这条指令预期一个字符串,JIT编译器就变得很囧很暴力(unhappy & outspoken),从而抛出一个Unexpected Type异常并终止编译。

     ldstr "Enter a number" 这条指令从指定字符串常量创建了一个字符串对象,并加载了一个对栈上这个对象的引用。这个字符串常量,在这个例子中,存储在元数据里。你可以称之为CLR字符串常量或元数据字符串常量。你可以另一种方式存储并处理这个字符串常量,这将在下面一段时间内介绍,而ldstr是专门用来处理CLR字符串常量的,这个字符串常量总是被存储为UnicodeUnicode-16)的格式。

     call void [mscorlib]System.Console::WriteLine(string) 这条指令调用了.NET Framework类库的一个控制台输出方法。这个字符串从栈中取出并作为方法的实参,而没有元素被放回到栈中,因为这个方法返回void

     这条指令的形参是一个名为Member ReferenceMemberRef)的元数据项。它指向WriteLine这个具有void(string)的签名的静态方法;该方法是System.Console类的成员,声明在外部的mscorlib程序集中。MemberRefTypeRef的成员——在本章前面的“类声明”部分讨论过——正如同FieldDefMethodDef也是TypeDef的成员。然而,并没有独立的FieldRefMethodRefMemberRef包含了指向字段和方法的引用。

     你可以通过签名的不同,来区分字段引用和方法引用的不同。用作字段的MemberRef和用作方法的MemberRef,具有不同的调用约定和不同的签名结构。第8章讨论了签名,包括了MemberRef的细节。

     IL编译器是如何知道为MemberRef生成什么类型的签名呢?大部分来自于上下文。例如,如果MemberRef是调用指令的一个形参,那么它肯定是一个方法的MemberRef。在一些特定情形下,上下文也不是很清晰,编译器需要显示的详细说明,如:

     method void Odd.Or.Even :: Check()          field int32 Odd.Or.Even :: val

 

call string [mscorlib]System.Console::ReadLine()
ldsflda valuetype CharArray8 Format
ldsflda int32 Odd.or.Even::val
call vararg int32 sscanf(stringint8*, int32*)

     call string [mscorlib]System.Console::ReadLine() 这条指令调用了.NET Framework类库的一个控制台输入方法。没有从栈中取出任何东西,而一个字符串作为调用结果被放入栈中。

     ldsflda valuetype CharArray8 Format 这条指令加载了valuetype CharArray8类型的静态字段Format的地址。(字段和值类型的声明位于源代码的后面,将会在后面的章节讨论。)IL有单独的指令来加载实例和静态字段(ldfldldsfld)或它们的地址(ldfldaldsflda)。还要注意到,加载到栈上的“地址”,并不严格的称为一个地址(或者说C/C++的指针),而是一个对项(在这个示例中是一个字段)的引用。

     正如你可能猜到的那样,valuetype CharArray8 Format是一个MemberRef,这次是指向valuetype CharArray8类型的Format字段。因为这样的MemberRef不归属于任何TypeRef,所以它必须是一个全局项。(接下来的章节讨论全局项的声明。)此外,这样的MemberRef不归属于任何外部的解析范围(resolution scope),如[mscorlib]。因此,它必须是一个定义在当前模块中某个地方的全局项。

     ldsflda int32 Odd.or.Even::val 这条指令加载了静态字段val的地址,作为Odd.or.Even类的成员,具有int32类型。但是因为这个正在讨论的方法也是Odd.or.Even类的成员,那么为什么需要在指向同一个类的成员时,指出类的完整名称呢?这就是ILAsm的规则:所有的引用必须是全限定名称(fully qualified)。和那些高级语言相比,这看上去有点笨,但是也有其好处。你不需要保持对上下文的跟踪,同时所有指向同一项的引用,在整个源代码中看上去都一样。而IL编译器也不需要加载和检查引用到的程序集来解析模棱两可的引用,这就意味着,即使缺少引用到的程序集和模块,IL编译器仍能编译一个模块(如果你没有使用被引用程序集的自动侦测,当然就不会有程序集被侦测到)。

     因为Odd.or.Even类和它的字段val都是在同一个模块中声明的,ILAsm编译器将不会生成MemberRef项,但是会代替的使用FieldDef项。这种方式就不需要在运行期解析引用了。

     call vararg int32 sscanf(string, int8*, ..., int32*) 这条指令调用了全局静态方法sscanf。这个方法从当前栈上取出三个项(返回的字符串来自System::Console::ReadLine,是对全局字段Format和字段Odd.or.Even::val的引用,并把int32类型的结果放到栈上。

     这个方法调用有两个主要的特征。首先,这是一个从C运行时的库到非托管方法的调用,我将延迟对这个问题的解释,直至我讨论到这个方法的声明。(对此我有一个正式的理由,毕竟,在调用方看来,托管代码和非托管代码是一样的。)

     这个方法的第二个特点是它的调用约定,vararg,意味着这个方法有一个参数变量的清单。vararg方法有一些强制性的形参(或者没有),继之以不定数量的可选的不定类型的形参——都是不定的,在方法声明的时候。当一个方法被调用的时候,所有的强制性参数(如果存在)和所有在这个调用中的可选的参数(如果存在),都应该被显示的指出。

     包包译注:CLR中的可选性参数,可以大致理解为C#方法函数中的params关键字,或者VB.NET中的Optional关键字     

     让我们仔细看一下在这个调用中的参数清单。这个省略涉及到了一种特殊类型的情况,被称为sentinel。sentinel的作用可以叙述为:“将强制性的参数与可选性的参数分开”,但是我认为有种比较模棱两可的说法:sentinel是先于可选性的参数的,并且它是vararg签名的可选部分的前缀。

     那么区别是什么呢?与vararg方法签名相关的CLR铁定规则,决定了sentinel在没有指定可选性参数时不可以使用。因此,sentinel不会出现在MethodDef签名中——在声明一个方法时只指名强制性参数——也不会在只提供强制性参数时出现在调用方的签名中。包括了尾部的sentinel的那些签名,是不合法的。这是我为什么认为很重要的地方:把sentinel看成可选性参数的开始,而不是作为强制性参数和可选性参数之间的一个分隔符(严重禁止!),也不是强制性参数的结束。

     由于这些与C运行时有点类似,我必须注意到sscanf方法,根据格式化字符串(第二个参数),从语法上解析并转换了缓冲字符串(第一个参数),将结果放到指针参数的剩余部分,并返回成功转换的项的数量。在这个例子中,只有一项将被转换,因此sscanf成功时返回或者失败返回0

 

stloc Retval
ldloc Retval
brfalse Error

     stloc Retval这个指令从栈上获取调用sscanf的结果,并将其存储在本地变量Retval中。你需要将这个值保存在本地变量,因为你将在后面用到它。

     ldloc Retval复制了Retval这个值并将其返回到栈上。你需要检测这个值,并通过stloc指令,将其从栈上取出。

     brfalse Error从栈上取出一项。如果这项是0,就会跳转到Error这个标签的分支上。

 

ldsfld int32 Odd.or.Even::val
ldc.i4 1
and
brfalse ItsEven
ldstr "odd!"
br PrintAndReturn

     ldsfld int32 Odd.or.Even::val 这条指令从栈上加载了静态字Odd.or.Even::val的值。如果这段代码进行地远,这种字符串到字符串的转换就一定是成功的,这种转换得出来的值也一定位于val字段上。在你最后一次访问这个字段的地址时,使用了ldsflda指令从栈上加载这个字段的地址。而这一次你需要的是值,所以要使用ldsfld

     ldc.i4 1这条指令从栈上加载了int32类型的常量1

     and指令从栈上获取两个项——val字段的值和整型常量1,执行AND位运算操作并把结果放入栈中。执行这个与1AND位运算操作,会导致val这个值的所有位都会变成0——除去最低有效位(least-significant bit)。

     包包译注:最低有效位(LSB)是给这些单元值的一个二进制整数位位置,就是,决定是否这个数字是偶数或奇数。LSB有时候是指最右边的位,因为写较不重要的数字到右边位置符号的协定。它类似于一个十进制整数的最不重要的数字,它是在一个(最右边)位置的数字。     

     brfalse ItsEven从栈上得到一个项(AND位运算操作的结果),如果它是0,程序就会跳转到ItsEven这个标签的分支上。如果val的值是偶数,那么先前的指令结果就是0;否则就得到1

     ldstr "odd!"这条指令从栈上加载字符串:odd!

     br PrintAndReturn这条指令没有触及到栈,只是无条件的跳转到PrintAndReturn标签的分支上。

 

     Odd.or.Even::Check方法中,剩下的代码就很清晰了。这一部分覆盖了在方法中使用到的所有指令——除了ret,它的意思很明显,返回栈上的无论什么东西。如果这个方法的返回类型,并不匹配栈上项的类型,JIT编译器就不会通过,抛出一个异常并终止编译。同样,如果在到达ret之前,栈上有多于一个的项;或者这个方法应该返回void(也就是说,没有任何返回),而栈上仍然有一个项;相反地,如果这个方法应该返回什么而这个栈却是空的——以上这些都会导致异常的发生。

 

 

posted @ 2008-07-24 16:51  包建强  Views(2202)  Comments(5Edit  收藏  举报